Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features

- Session Replay: Record transaction segment names ([#5763](https://github.com/getsentry/sentry-java/pull/5763))
Comment thread
Copilot marked this conversation as resolved.
Outdated

- Add `io.sentry:sentry-opentelemetry-bom` to align Sentry OpenTelemetry modules with tested OpenTelemetry dependencies ([#5629](https://github.com/getsentry/sentry-java/pull/5629))
- Spring Boot Gradle plugin: add the Sentry BOM to `dependencyManagement`; explicit imports are applied after Spring Boot's implicit BOM
```kotlin
Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
public fun onWindowSizeChanged (II)V
public fun pause ()V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
public fun registerSegmentName (Ljava/lang/String;)V
public fun registerTraceId (Lio/sentry/protocol/SentryId;)V
public fun resume ()V
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ public class ReplayIntegration(
captureStrategy?.registerTraceId(traceId)
}

override fun registerSegmentName(segmentName: String) {
if (!isEnabled.get() || !isRecording()) {
return
}
captureStrategy?.registerSegmentName(segmentName)
}

private fun pauseInternal() {
lifecycleLock.acquire().use {
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ internal abstract class BaseCaptureStrategy(
internal companion object {
private const val TAG = "CaptureStrategy"
// https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41
private const val MAX_TRACE_IDS = 100
private const val MAX_CONTEXT_VALUES = 100
}

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

protected val currentEvents: Deque<RRWebEvent> = ConcurrentLinkedDeque()
private val traceIdsLock = Any()
private val currentTraceIds: MutableList<String> = mutableListOf()
private val replayContextLock = Any()
private val currentTraceIds: MutableSet<String> = linkedSetOf()
private val currentSegmentNames: MutableSet<String> = linkedSetOf()

override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) {
cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId)
Expand Down Expand Up @@ -139,11 +140,12 @@ internal abstract class BaseCaptureStrategy(
breadcrumbs: List<Breadcrumb>? = null,
events: Deque<RRWebEvent> = this.currentEvents,
): ReplaySegment {
val traceIds =
synchronized(traceIdsLock) {
val ids = currentTraceIds.toList()
val (traceIds, segmentNames) =
synchronized(replayContextLock) {
val context = currentTraceIds.toList() to currentSegmentNames.toList()
currentTraceIds.clear()
ids
currentSegmentNames.clear()
context
}
return createSegment(
scopes,
Expand All @@ -162,6 +164,7 @@ internal abstract class BaseCaptureStrategy(
breadcrumbs,
events,
traceIds,
segmentNames,
)
}

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

override fun registerTraceId(traceId: SentryId) {
if (traceId != SentryId.EMPTY_ID) {
synchronized(traceIdsLock) {
if (currentTraceIds.size < MAX_TRACE_IDS) {
val id = traceId.toString()
if (!currentTraceIds.contains(id)) {
currentTraceIds.add(id)
}
synchronized(replayContextLock) {
if (currentTraceIds.size < MAX_CONTEXT_VALUES) {
currentTraceIds.add(traceId.toString())
}
}
}
}

override fun registerSegmentName(segmentName: String) {
if (segmentName.isNotEmpty()) {
synchronized(replayContextLock) {
if (currentSegmentNames.size < MAX_CONTEXT_VALUES) {
currentSegmentNames.add(segmentName)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ internal interface CaptureStrategy {

fun registerTraceId(traceId: SentryId)

fun registerSegmentName(segmentName: String)

companion object {
private fun Breadcrumb?.isNetworkAvailable(): Boolean =
this != null &&
Expand Down Expand Up @@ -88,6 +90,7 @@ internal interface CaptureStrategy {
breadcrumbs: List<Breadcrumb>?,
events: Deque<RRWebEvent>,
traceIds: List<String> = emptyList(),
segmentNames: List<String> = emptyList(),
): ReplaySegment {
val generatedVideo =
cache?.createVideoOf(
Expand Down Expand Up @@ -127,6 +130,7 @@ internal interface CaptureStrategy {
replayBreadcrumbs,
events,
traceIds,
segmentNames,
)
}

Expand All @@ -147,6 +151,7 @@ internal interface CaptureStrategy {
breadcrumbs: List<Breadcrumb>,
events: Deque<RRWebEvent>,
traceIds: List<String>,
segmentNames: List<String>,
): ReplaySegment {
val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration)
val replay =
Expand All @@ -159,6 +164,7 @@ internal interface CaptureStrategy {
this.replayType = replayType
this.videoFile = video
this.traceIds = traceIds
this.segmentNames = segmentNames
}

val recordingPayload = mutableListOf<RRWebEvent>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,4 +562,58 @@ class SessionCaptureStrategyTest {
any(),
)
}

@Test
fun `registerSegmentName includes unique segment names in next segment and clears them`() {
val now =
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(fixture.recorderConfig)

strategy.registerSegmentName("CheckoutActivity")
strategy.registerSegmentName("CheckoutActivity")
strategy.registerSegmentName("ProductDetailsActivity")

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event ->
event is SentryReplayEvent &&
event.segmentNames == listOf("CheckoutActivity", "ProductDetailsActivity")
},
any(),
)

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event ->
event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty()
},
any(),
)
}

@Test
fun `registerSegmentName ignores empty names and limits names to 100`() {
val now =
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(fixture.recorderConfig)

strategy.registerSegmentName("")
repeat(101) { strategy.registerSegmentName("ProductActivity$it") }

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 },
any(),
)
}
}
5 changes: 5 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController {
public fun isDebugMaskingOverlayEnabled ()Z
public fun isRecording ()Z
public fun pause ()V
public fun registerSegmentName (Ljava/lang/String;)V
public fun registerTraceId (Lio/sentry/protocol/SentryId;)V
public fun resume ()V
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
Expand Down Expand Up @@ -2357,6 +2358,7 @@ public abstract interface class io/sentry/ReplayController : io/sentry/IReplayAp
public abstract fun isDebugMaskingOverlayEnabled ()Z
public abstract fun isRecording ()Z
public abstract fun pause ()V
public abstract fun registerSegmentName (Ljava/lang/String;)V
public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V
public abstract fun resume ()V
public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
Expand Down Expand Up @@ -4023,6 +4025,7 @@ public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/s
public fun getReplayStartTimestamp ()Ljava/util/Date;
public fun getReplayType ()Lio/sentry/SentryReplayEvent$ReplayType;
public fun getSegmentId ()I
public fun getSegmentNames ()Ljava/util/List;
public fun getTimestamp ()Ljava/util/Date;
public fun getTraceIds ()Ljava/util/List;
public fun getType ()Ljava/lang/String;
Expand All @@ -4036,6 +4039,7 @@ public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/s
public fun setReplayStartTimestamp (Ljava/util/Date;)V
public fun setReplayType (Lio/sentry/SentryReplayEvent$ReplayType;)V
public fun setSegmentId (I)V
public fun setSegmentNames (Ljava/util/List;)V
public fun setTimestamp (Ljava/util/Date;)V
public fun setTraceIds (Ljava/util/List;)V
public fun setType (Ljava/lang/String;)V
Expand All @@ -4056,6 +4060,7 @@ public final class io/sentry/SentryReplayEvent$JsonKeys {
public static final field REPLAY_START_TIMESTAMP Ljava/lang/String;
public static final field REPLAY_TYPE Ljava/lang/String;
public static final field SEGMENT_ID Ljava/lang/String;
public static final field SEGMENT_NAMES Ljava/lang/String;
public static final field TIMESTAMP Ljava/lang/String;
public static final field TRACE_IDS Ljava/lang/String;
public static final field TYPE Ljava/lang/String;
Expand Down
3 changes: 3 additions & 0 deletions sentry/src/main/java/io/sentry/NoOpReplayController.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,7 @@ public void disableDebugMaskingOverlay() {}

@Override
public void registerTraceId(@NotNull SentryId traceId) {}

@Override
public void registerSegmentName(@NotNull String segmentName) {}
}
3 changes: 3 additions & 0 deletions sentry/src/main/java/io/sentry/ReplayController.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,7 @@ public interface ReplayController extends IReplayApi {
* @param traceId the trace ID to associate with the current replay
*/
void registerTraceId(@NotNull SentryId traceId);

/** Registers a segment name to be associated with the current replay segment. */
void registerSegmentName(@NotNull String segmentName);
}
4 changes: 4 additions & 0 deletions sentry/src/main/java/io/sentry/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,10 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
if (trace != null) {
options.getReplayController().registerTraceId(trace.getTraceId());
}
final @Nullable String segmentName = transaction.getTransaction();
if (segmentName != null && !segmentName.isEmpty()) {
options.getReplayController().registerSegmentName(segmentName);
}
}

return sentryId;
Expand Down
26 changes: 24 additions & 2 deletions sentry/src/main/java/io/sentry/SentryReplayEvent.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public static final class Deserializer implements JsonDeserializer<ReplayType> {
private @Nullable List<String> urls;
private @Nullable List<String> errorIds;
private @Nullable List<String> traceIds;
private @Nullable List<String> segmentNames;
private @Nullable Map<String, Object> unknown;

public SentryReplayEvent() {
Expand All @@ -58,6 +59,7 @@ public SentryReplayEvent() {
this.replayType = ReplayType.SESSION;
this.errorIds = new ArrayList<>();
this.traceIds = new ArrayList<>();
this.segmentNames = new ArrayList<>();
this.urls = new ArrayList<>();
timestamp = DateUtils.getCurrentDateTime();
}
Expand Down Expand Up @@ -142,6 +144,15 @@ public void setTraceIds(final @Nullable List<String> traceIds) {
this.traceIds = traceIds;
}

@Nullable
public List<String> getSegmentNames() {
return segmentNames;
}

public void setSegmentNames(final @Nullable List<String> segmentNames) {
this.segmentNames = segmentNames;
}

@NotNull
public ReplayType getReplayType() {
return replayType;
Expand All @@ -162,12 +173,14 @@ public boolean equals(Object o) {
&& Objects.equals(replayId, that.replayId)
&& Objects.equals(urls, that.urls)
&& Objects.equals(errorIds, that.errorIds)
&& Objects.equals(traceIds, that.traceIds);
&& Objects.equals(traceIds, that.traceIds)
&& Objects.equals(segmentNames, that.segmentNames);
}

@Override
public int hashCode() {
return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds);
return Objects.hash(
type, replayType, replayId, segmentId, urls, errorIds, traceIds, segmentNames);
}

// region json
Expand All @@ -181,6 +194,7 @@ public static final class JsonKeys {
public static final String URLS = "urls";
public static final String ERROR_IDS = "error_ids";
public static final String TRACE_IDS = "trace_ids";
public static final String SEGMENT_NAMES = "segment_names";
}

@Override
Expand All @@ -207,6 +221,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
if (traceIds != null) {
writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds);
}
if (segmentNames != null) {
writer.name(JsonKeys.SEGMENT_NAMES).value(logger, segmentNames);
}

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

Expand Down Expand Up @@ -250,6 +267,7 @@ public static final class Deserializer implements JsonDeserializer<SentryReplayE
@Nullable List<String> urls = null;
@Nullable List<String> errorIds = null;
@Nullable List<String> traceIds = null;
@Nullable List<String> segmentNames = null;

reader.beginObject();
while (reader.peek() == JsonToken.NAME) {
Expand Down Expand Up @@ -282,6 +300,9 @@ public static final class Deserializer implements JsonDeserializer<SentryReplayE
case JsonKeys.TRACE_IDS:
traceIds = (List<String>) reader.nextObjectOrNull();
break;
case JsonKeys.SEGMENT_NAMES:
segmentNames = (List<String>) reader.nextObjectOrNull();
break;
default:
if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) {
if (unknown == null) {
Expand Down Expand Up @@ -311,6 +332,7 @@ public static final class Deserializer implements JsonDeserializer<SentryReplayE
replay.setUrls(urls);
replay.setErrorIds(errorIds);
replay.setTraceIds(traceIds);
replay.setSegmentNames(segmentNames);
replay.setUnknown(unknown);
return replay;
}
Expand Down
20 changes: 20 additions & 0 deletions sentry/src/test/java/io/sentry/SentryClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,8 @@ class SentryClientTest {
override fun registerTraceId(traceId: SentryId) {
registeredTraceId = traceId
}

override fun registerSegmentName(segmentName: String) {}
}
)
val sut = fixture.getSut()
Expand All @@ -2139,6 +2141,24 @@ class SentryClientTest {
assertEquals(sentryTracer.spanContext.traceId, registeredTraceId)
}

@Test
fun `captureTransaction registers segment name with replay controller`() {
var registeredSegmentName: String? = null
fixture.sentryOptions.setReplayController(
object : ReplayController by NoOpReplayController.getInstance() {
override fun registerSegmentName(segmentName: String) {
registeredSegmentName = segmentName
}
}
)
val sut = fixture.getSut()
val sentryTracer =
SentryTracer(TransactionContext("CheckoutActivity", "ui.load"), fixture.scopes)
val transaction = SentryTransaction(sentryTracer)
sut.captureTransaction(transaction, sentryTracer.traceContext())
assertEquals("CheckoutActivity", registeredSegmentName)
}

@Test
fun `when exception type is ignored, capturing event does not send it`() {
fixture.sentryOptions.addIgnoredExceptionForType(IllegalStateException::class.java)
Expand Down
Loading
Loading