Skip to content

Commit

Permalink
[SR] Session Replay (#3339)
Browse files Browse the repository at this point in the history
* Add new sentry-android-replay module

* Add screenshot recorder

* Add sentry replay envelope and event

* Add TODOs and license headers

* Api dump

* Formatting

* Lint

* Format code

* More comments

* Disable detekt plugin for now

* WIP

* Add replay envelopes

* Remove jsonValue

* Remove

* Fix json

* Finalize replay envelopes

* Introduce MapObjectReader

* Add missing test

* Add test  for MapObjectReader

* Add MapObjectWriter change

* Add finals

* Fix test

* Fix test

* Address review

* Add finals and annotations

* Specify SHA for license headers

* Address review from Dhiogo

* Address review from Markus

* Remove public captureReplay method

* Fix test

* api dump

* api dump

* Address review from Markus

* Api dump

* Add replay integration

* Uncomment redacting

* Update proguard rules

* Add missing rule for AndroidTest

* Add ReplayCache tests

* Add tests

* Add SessionReplayOptions

* Call listeners when installing RootViewsSpy

* Call listeners when installing RootViewsSpy

* SessionReplayOptions -> SentryReplayOptions

* Fix test

* Add AndroidManifest options for replays

* Add buffer mode and link replays with events/transactions

* Pass hint to captureReplay

* Better error handling

* recycler lastScreenshot before re-assigning

* Expose ReplayCache as public api

* Fix redacting out of sync

* _experimental -> experimental

* Merge conflicts

* Fix tests

* Add more tests

* Improve ReplayCache logic

* frameUsec -> frameDurationUsec

* bottom/right -> height/width

* add todos

* duration -> durationMs

* replaId non-nullable

* More conflicts

* More conflicts

* Fix tests

* Address PR review

* Add kdoc

* Add kdoc

* Fix tests

* Add comment for experimental options

* Do not run recorder if full session was not sampled

* Add more tests

* Add session deadline of 1h

* Clean up older replays when starting a new one

* Remove unnecessary extension fun

* Safe executors

* Fix crashing MediaCodec and use density to determine recording resolution

* Add redact options and align naming

* Fix tests

* Fix tests

* WIP

* Try-catch release of encoder

* Support orientation change for session mode

* WIP

* Spotless

* TODO

* Update sentry/src/main/java/io/sentry/SentryReplayOptions.java

Co-authored-by: Markus Hintersteiner <markus.hintersteiner@sentry.io>

* More gates

* Revert addAll

* Fix conflicts

* fix test

* release: 7.8.0-alpha.0

* Introduce CaptureStrategy for buffer and session modes

* Formatting

* WIP

* Expose public API for flutter

* Spotless

* Spotless

* Remove breadcrumb import

* Send temporary breadcrumbs and add test

* Formatting

* Sort rrweb events

* Formatting

* Expose replayCacheDir

* Capture network requests

* Change op name to resource.http

* feat(replay): Add `sendReplay` method for Hybrid SDKs

* fix apiDump

* Address PR review

* Capture motion events as incremental rrweb events

* Spotless

* Revert

* Changelog

* release: 7.9.0-alpha.1

* Fix test

* WIP

* Adhere to rrweb move event expectations

* formatting

* Align breadcrumbs with frontend and iOS

* Add tests and fix deserialization

* Rotate buffered motion events in buffer mode

* Add Nullables

* Address PR feedback

* Formatting

* Rotate current events until segment end exclusively

* Allow rrweb breadcrumb customization from hybrid SDKs

* Fix proguard rules

* WIP

* Add tests

* Detect obscured views

* revert some thigns

* Remove commented code

* Suppress lint

* Support multi-touch gestures

* Address PR feedback

* Changelog

* release: 7.11.0-alpha.2

* Make multi-touch work

* Fix tests

* WIP

* Capture screen names as urls for replay

* Fix

* Ignore warning

* Address PR feedback

* Tests

* Add quality settings

* Fix redacting out of sync

* Remove time measuring

* Mark isEnableScreenTracking as experimental

* Format code

* Address PR feedback

* Clean up

* Spotless

* Format code

* Changelog

* release: 7.12.0-alpha.3

* [SR] Add `redactClasses` option (#3546)

Co-authored-by: Roman Zavarnitsyn <rom4ek93@gmail.com>

* misc(changelog): Prepare for next alpha

* fix(changelog): Bump alpha version number

* release: 7.12.0-alpha.4

* Redaction fixes for RN

* Add stopgap for offline session recording

* Recycle unused bitmap

* Add tests for sentry

* Add ReplayIntegrationTest

* Replay SmokeTest

* Fix test

* Fix test

* Fix events linking with buffered replays

* Changelog

---------

Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io>
Co-authored-by: Markus Hintersteiner <markus.hintersteiner@sentry.io>
Co-authored-by: getsentry-bot <bot@sentry.io>
Co-authored-by: getsentry-bot <bot@getsentry.com>
Co-authored-by: Krystof Woldrich <krystof.woldrich@sentry.io>
Co-authored-by: Krystof Woldrich <31292499+krystofwoldrich@users.noreply.github.com>
  • Loading branch information
7 people committed Jul 15, 2024
1 parent 25f1ca4 commit e34c467
Show file tree
Hide file tree
Showing 197 changed files with 12,153 additions and 452 deletions.
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## Unreleased

### Features

- Session Replay Public Beta ([#3339](https://github.com/getsentry/sentry-java/pull/3339))

To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.errorSampleRate` experimental options.

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

SentryAndroid.init(context) { options ->

// Currently under experimental options:
options.experimental.sessionReplay.sessionSampleRate = 1.0
options.experimental.sessionReplay.errorSampleRate = 1.0

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

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

To learn more visit [Sentry's Mobile Session Replay](https://docs.sentry.io/product/explore/session-replay/mobile/) documentation page.

## 7.11.0

### Features
Expand Down
5 changes: 4 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ subprojects {
"sentry-android-ndk",
"sentry-android-okhttp",
"sentry-android-sqlite",
"sentry-android-replay",
"sentry-android-timber"
)
if (jacocoAndroidModules.contains(name)) {
Expand Down Expand Up @@ -296,7 +297,9 @@ private val androidLibs = setOf(
"sentry-android-navigation",
"sentry-android-okhttp",
"sentry-android-timber",
"sentry-compose-android"
"sentry-compose-android",
"sentry-android-sqlite",
"sentry-android-replay"
)

private val androidXLibs = listOf(
Expand Down
2 changes: 2 additions & 0 deletions buildSrc/src/main/java/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ object Config {

val minSdkVersion = 19
val minSdkVersionOkHttp = 21
val minSdkVersionReplay = 19
val minSdkVersionNdk = 19
val minSdkVersionCompose = 21
val targetSdkVersion = sdkVersion
Expand Down Expand Up @@ -194,6 +195,7 @@ object Config {
val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0"
val hsqldb = "org.hsqldb:hsqldb:2.6.1"
val javaFaker = "com.github.javafaker:javafaker:1.0.2"
val msgpack = "org.msgpack:msgpack-core:0.9.8"
}

object QualityPlugins {
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true

# Release information
versionName=7.11.0
versionName=7.12.0-alpha.4

# Override the SDK name on native crashes on Android
sentryAndroidSdkName=sentry.native.android
Expand Down
2 changes: 2 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ public final class io/sentry/android/core/CurrentActivityIntegration : android/a
public final class io/sentry/android/core/DeviceInfoUtil {
public fun <init> (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V
public fun collectDeviceInformation (ZZ)Lio/sentry/protocol/Device;
public static fun getBatteryLevel (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Float;
public static fun getInstance (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/DeviceInfoUtil;
public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem;
public fun getSideLoadedInfo ()Lio/sentry/android/core/ContextUtils$SideLoadedInfo;
public static fun isCharging (Landroid/content/Intent;Lio/sentry/SentryOptions;)Ljava/lang/Boolean;
public static fun resetInstance ()V
}

Expand Down
2 changes: 2 additions & 0 deletions sentry-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dependencies {
api(projects.sentry)
compileOnly(projects.sentryAndroidFragment)
compileOnly(projects.sentryAndroidTimber)
compileOnly(projects.sentryAndroidReplay)
compileOnly(projects.sentryCompose)
compileOnly(projects.sentryComposeHelper)

Expand Down Expand Up @@ -104,6 +105,7 @@ dependencies {
testImplementation(projects.sentryTestSupport)
testImplementation(projects.sentryAndroidFragment)
testImplementation(projects.sentryAndroidTimber)
testImplementation(projects.sentryAndroidReplay)
testImplementation(projects.sentryComposeHelper)
testImplementation(projects.sentryAndroidNdk)
testRuntimeOnly(Config.Libs.composeUi)
Expand Down
6 changes: 6 additions & 0 deletions sentry-android-core/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,9 @@
-keepnames class io.sentry.exception.SentryHttpClientException

##---------------End: proguard configuration for sentry-okhttp ----------

##---------------Begin: proguard configuration for sentry-android-replay ----------
-dontwarn io.sentry.android.replay.ReplayIntegration
-dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter
-keepnames class io.sentry.android.replay.ReplayIntegration
##---------------End: proguard configuration for sentry-android-replay ----------
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ private void finishTransaction(
public synchronized void onActivityCreated(
final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) {
setColdStart(savedInstanceState);
if (hub != null) {
if (hub != null && options != null && options.isEnableScreenTracking()) {
final @Nullable String activityClassName = ClassUtil.getClassName(activity);
hub.configureScope(scope -> scope.setScreen(activityClassName));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.android.core.performance.AppStartMetrics;
import io.sentry.android.fragment.FragmentLifecycleIntegration;
import io.sentry.android.replay.DefaultReplayBreadcrumbConverter;
import io.sentry.android.replay.ReplayIntegration;
import io.sentry.android.timber.SentryTimberIntegration;
import io.sentry.cache.PersistingOptionsObserver;
import io.sentry.cache.PersistingScopeObserver;
import io.sentry.compose.gestures.ComposeGestureTargetLocator;
import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter;
import io.sentry.internal.gestures.GestureTargetLocator;
import io.sentry.internal.viewhierarchy.ViewHierarchyExporter;
import io.sentry.transport.CurrentDateProvider;
import io.sentry.transport.NoOpEnvelopeCache;
import io.sentry.util.LazyEvaluator;
import io.sentry.util.Objects;
Expand Down Expand Up @@ -237,7 +240,8 @@ static void installDefaultIntegrations(
final @NotNull LoadClass loadClass,
final @NotNull ActivityFramesTracker activityFramesTracker,
final boolean isFragmentAvailable,
final boolean isTimberAvailable) {
final boolean isTimberAvailable,
final boolean isReplayAvailable) {

// Integration MUST NOT cache option values in ctor, as they will be configured later by the
// user
Expand Down Expand Up @@ -302,6 +306,13 @@ static void installDefaultIntegrations(
new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger()));
options.addIntegration(new TempSensorBreadcrumbsIntegration(context));
options.addIntegration(new PhoneStateBreadcrumbsIntegration(context));
if (isReplayAvailable) {
final ReplayIntegration replay =
new ReplayIntegration(context, CurrentDateProvider.getInstance());
replay.setBreadcrumbConverter(new DefaultReplayBreadcrumbConverter());
options.addIntegration(replay);
options.setReplayController(replay);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.sentry.SentryBaseEvent;
import io.sentry.SentryEvent;
import io.sentry.SentryLevel;
import io.sentry.SentryReplayEvent;
import io.sentry.android.core.internal.util.AndroidMainThreadChecker;
import io.sentry.android.core.performance.AppStartMetrics;
import io.sentry.android.core.performance.TimeSpan;
Expand Down Expand Up @@ -303,4 +304,17 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) {

return transaction;
}

@Override
public @NotNull SentryReplayEvent process(
final @NotNull SentryReplayEvent event, final @NotNull Hint hint) {
final boolean applyScopeData = shouldApplyScopeData(event, hint);
if (applyScopeData) {
processNonCachedEvent(event, hint);
}

setCommons(event, false, applyScopeData);

return event;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import android.util.DisplayMetrics;
import io.sentry.DateUtils;
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.android.core.internal.util.CpuInfoUtils;
import io.sentry.android.core.internal.util.DeviceOrientations;
import io.sentry.android.core.internal.util.RootChecker;
Expand Down Expand Up @@ -184,8 +185,8 @@ public ContextUtils.SideLoadedInfo getSideLoadedInfo() {
private void setDeviceIO(final @NotNull Device device, final boolean includeDynamicData) {
final Intent batteryIntent = getBatteryIntent();
if (batteryIntent != null) {
device.setBatteryLevel(getBatteryLevel(batteryIntent));
device.setCharging(isCharging(batteryIntent));
device.setBatteryLevel(getBatteryLevel(batteryIntent, options));
device.setCharging(isCharging(batteryIntent, options));
device.setBatteryTemperature(getBatteryTemperature(batteryIntent));
}

Expand Down Expand Up @@ -270,7 +271,8 @@ private Intent getBatteryIntent() {
* @return the device's current battery level (as a percentage of total), or null if unknown
*/
@Nullable
private Float getBatteryLevel(final @NotNull Intent batteryIntent) {
public static Float getBatteryLevel(
final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) {
try {
int level = batteryIntent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = batteryIntent.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
Expand All @@ -294,7 +296,8 @@ private Float getBatteryLevel(final @NotNull Intent batteryIntent) {
* @return whether or not the device is currently plugged in and charging, or null if unknown
*/
@Nullable
private Boolean isCharging(final @NotNull Intent batteryIntent) {
public static Boolean isCharging(
final @NotNull Intent batteryIntent, final @NotNull SentryOptions options) {
try {
int plugged = batteryIntent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
return plugged == BatteryManager.BATTERY_PLUGGED_AC
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import io.sentry.transport.ICurrentDateProvider;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
Expand All @@ -19,11 +20,12 @@
final class LifecycleWatcher implements DefaultLifecycleObserver {

private final AtomicLong lastUpdatedSession = new AtomicLong(0L);
private final AtomicBoolean isFreshSession = new AtomicBoolean(false);

private final long sessionIntervalMillis;

private @Nullable TimerTask timerTask;
private final @Nullable Timer timer;
private final @NotNull Timer timer = new Timer(true);
private final @NotNull Object timerLock = new Object();
private final @NotNull IHub hub;
private final boolean enableSessionTracking;
Expand Down Expand Up @@ -55,11 +57,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver {
this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs;
this.hub = hub;
this.currentDateProvider = currentDateProvider;
if (enableSessionTracking) {
timer = new Timer(true);
} else {
timer = null;
}
}

// App goes to foreground
Expand All @@ -74,41 +71,46 @@ public void onStart(final @NotNull LifecycleOwner owner) {
}

private void startSession() {
if (enableSessionTracking) {
cancelTask();
cancelTask();

final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis();
final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis();

hub.configureScope(
scope -> {
if (lastUpdatedSession.get() == 0L) {
final @Nullable Session currentSession = scope.getSession();
if (currentSession != null && currentSession.getStarted() != null) {
lastUpdatedSession.set(currentSession.getStarted().getTime());
}
hub.configureScope(
scope -> {
if (lastUpdatedSession.get() == 0L) {
final @Nullable Session currentSession = scope.getSession();
if (currentSession != null && currentSession.getStarted() != null) {
lastUpdatedSession.set(currentSession.getStarted().getTime());
isFreshSession.set(true);
}
});
}
});

final long lastUpdatedSession = this.lastUpdatedSession.get();
if (lastUpdatedSession == 0L
|| (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) {
final long lastUpdatedSession = this.lastUpdatedSession.get();
if (lastUpdatedSession == 0L
|| (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) {
if (enableSessionTracking) {
addSessionBreadcrumb("start");
hub.startSession();
}
this.lastUpdatedSession.set(currentTimeMillis);
hub.getOptions().getReplayController().start();
} else if (!isFreshSession.get()) {
// only resume if it's not a fresh session, which has been started in SentryAndroid.init
hub.getOptions().getReplayController().resume();
}
isFreshSession.set(false);
this.lastUpdatedSession.set(currentTimeMillis);
}

// App went to background and triggered this callback after 700ms
// as no new screen was shown
@Override
public void onStop(final @NotNull LifecycleOwner owner) {
if (enableSessionTracking) {
final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis();
this.lastUpdatedSession.set(currentTimeMillis);
final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis();
this.lastUpdatedSession.set(currentTimeMillis);

scheduleEndSession();
}
hub.getOptions().getReplayController().pause();
scheduleEndSession();

AppState.getInstance().setInBackground(true);
addAppBreadcrumb("background");
Expand All @@ -122,8 +124,11 @@ private void scheduleEndSession() {
new TimerTask() {
@Override
public void run() {
addSessionBreadcrumb("end");
hub.endSession();
if (enableSessionTracking) {
addSessionBreadcrumb("end");
hub.endSession();
}
hub.getOptions().getReplayController().stop();
}
};

Expand Down Expand Up @@ -164,7 +169,7 @@ TimerTask getTimerTask() {
}

@TestOnly
@Nullable
@NotNull
Timer getTimer() {
return timer;
}
Expand Down
Loading

0 comments on commit e34c467

Please sign in to comment.