Skip to content

Commit

Permalink
feat: Start/Stop session replay (#4414)
Browse files Browse the repository at this point in the history
Added Api to manually control session replay start and stop
  • Loading branch information
brustolin authored Oct 11, 2024
1 parent eae2b59 commit 2d35ccb
Show file tree
Hide file tree
Showing 11 changed files with 191 additions and 39 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- feat: API to manually start/stop Session Replay (#4414)
- Custom redact modifier for SwiftUI (#4362, #4392)

### Removal of Experimental API
Expand All @@ -20,6 +21,7 @@ via the option `swizzleClassNameExclude`.
### Improvements

- Serializing profile on a BG Thread (#4377) to avoid potentially slightly blocking the main thread.
- Session Replay performance for SwiftUI (#4419)

## 8.38.0-beta.1

Expand Down
14 changes: 14 additions & 0 deletions Sources/Sentry/Public/SentryReplayApi.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (void)resume;

/**
* Start recording a session replay if not started.
*
* @warning This is an experimental feature and may still have bugs.
*/
- (void)start;

/**
* Stop the current session replay recording.
*
* @warning This is an experimental feature and may still have bugs.
*/
- (void)stop;

@end

NS_ASSUME_NONNULL_END
Expand Down
1 change: 0 additions & 1 deletion Sources/Sentry/SentryOptions.m
Original file line number Diff line number Diff line change
Expand Up @@ -804,5 +804,4 @@ - (NSString *)debugDescription
return [NSString stringWithFormat:@"<%@: {\n%@\n}>", self, propertiesDescription];
}
#endif // defined(DEBUG) || defined(TEST) || defined(TESTCI)

@end
39 changes: 34 additions & 5 deletions Sources/Sentry/SentryReplayApi.m
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
#if SENTRY_TARGET_REPLAY_SUPPORTED

# import "SentryHub+Private.h"
# import "SentryOptions+Private.h"
# import "SentrySDK+Private.h"
# import "SentrySessionReplayIntegration.h"
# import "SentrySessionReplayIntegration+Private.h"
# import "SentrySwift.h"
# import <UIKit/UIKit.h>

Expand All @@ -22,18 +23,46 @@ - (void)unmaskView:(UIView *)view

- (void)pause
{
SentrySessionReplayIntegration *replayIntegration =
[SentrySDK.currentHub getInstalledIntegration:SentrySessionReplayIntegration.class];
SentrySessionReplayIntegration *replayIntegration
= (SentrySessionReplayIntegration *)[SentrySDK.currentHub
getInstalledIntegration:SentrySessionReplayIntegration.class];
[replayIntegration pause];
}

- (void)resume
{
SentrySessionReplayIntegration *replayIntegration =
[SentrySDK.currentHub getInstalledIntegration:SentrySessionReplayIntegration.class];
SentrySessionReplayIntegration *replayIntegration
= (SentrySessionReplayIntegration *)[SentrySDK.currentHub
getInstalledIntegration:SentrySessionReplayIntegration.class];
[replayIntegration resume];
}

- (void)start
{
SentrySessionReplayIntegration *replayIntegration
= (SentrySessionReplayIntegration *)[SentrySDK.currentHub
getInstalledIntegration:SentrySessionReplayIntegration.class];

if (replayIntegration == nil) {
SentryOptions *currentOptions = SentrySDK.currentHub.client.options;
replayIntegration =
[[SentrySessionReplayIntegration alloc] initForManualUse:currentOptions];

[SentrySDK.currentHub addInstalledIntegration:replayIntegration
name:NSStringFromClass(SentrySessionReplay.class)];
}

[replayIntegration start];
}

- (void)stop
{
SentrySessionReplayIntegration *replayIntegration
= (SentrySessionReplayIntegration *)[SentrySDK.currentHub
getInstalledIntegration:SentrySessionReplayIntegration.class];
[replayIntegration stop];
}

@end

#endif
83 changes: 58 additions & 25 deletions Sources/Sentry/SentrySessionReplayIntegration.m
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@
*/
static SentryTouchTracker *_touchTracker;

static SentrySessionReplayIntegration *_installedInstance;

@interface SentrySessionReplayIntegration () <SentryReachabilityObserver>
- (void)newSceneActivate;
@end
Expand All @@ -50,9 +48,20 @@ @implementation SentrySessionReplayIntegration {
SentryOnDemandReplay *_resumeReplayMaker;
}

+ (nullable SentrySessionReplayIntegration *)installed
- (instancetype)init
{
self = [super init];
return self;
}

- (instancetype)initForManualUse:(nonnull SentryOptions *)options
{
return _installedInstance;
if (self = [super init]) {
[self setupWith:options.experimental.sessionReplay
enableTouchTracker:options.enableSwizzling];
[self startWithOptions:options.experimental.sessionReplay fullSession:YES];
}
return self;
}

- (BOOL)installWithOptions:(nonnull SentryOptions *)options
Expand All @@ -61,14 +70,19 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options
return NO;
}

_replayOptions = options.experimental.sessionReplay;
_viewPhotographer =
[[SentryViewPhotographer alloc] initWithRedactOptions:options.experimental.sessionReplay];
[self setupWith:options.experimental.sessionReplay enableTouchTracker:options.enableSwizzling];
return YES;
}

- (void)setupWith:(SentryReplayOptions *)replayOptions enableTouchTracker:(BOOL)touchTracker
{
_replayOptions = replayOptions;
_viewPhotographer = [[SentryViewPhotographer alloc] initWithRedactOptions:replayOptions];

if (options.enableSwizzling) {
if (touchTracker) {
_touchTracker = [[SentryTouchTracker alloc]
initWithDateProvider:SentryDependencyContainer.sharedInstance.dateProvider
scale:options.experimental.sessionReplay.sizeScale];
scale:replayOptions.sizeScale];
[self swizzleApplicationTouch];
}

Expand All @@ -87,9 +101,6 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options
}];

[SentryDependencyContainer.sharedInstance.reachability addObserver:self];

_installedInstance = self;
return YES;
}

/**
Expand Down Expand Up @@ -212,23 +223,30 @@ - (void)startSession
return;
}

[self runReplayForAvailableWindow];
}

- (void)runReplayForAvailableWindow
{
if (SentryDependencyContainer.sharedInstance.application.windows.count > 0) {
// If a window its already available start replay right away
[self startWithOptions:_replayOptions fullSession:_startedAsFullSession];
} else {
} else if (@available(iOS 13.0, tvOS 13.0, *)) {
// Wait for a scene to be available to started the replay
if (@available(iOS 13.0, tvOS 13.0, *)) {
[_notificationCenter addObserver:self
selector:@selector(newSceneActivate)
name:UISceneDidActivateNotification];
}
[_notificationCenter addObserver:self
selector:@selector(newSceneActivate)
name:UISceneDidActivateNotification];
}
}

- (void)newSceneActivate
{
[SentryDependencyContainer.sharedInstance.notificationCenterWrapper removeObserver:self];
[self startWithOptions:_replayOptions fullSession:_startedAsFullSession];
if (@available(iOS 13.0, tvOS 13.0, *)) {
[SentryDependencyContainer.sharedInstance.notificationCenterWrapper
removeObserver:self
name:UISceneDidActivateNotification];
[self startWithOptions:_replayOptions fullSession:_startedAsFullSession];
}
}

- (void)startWithOptions:(SentryReplayOptions *)replayOptions
Expand Down Expand Up @@ -277,7 +295,7 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions

[self.sessionReplay
startWithRootView:SentryDependencyContainer.sharedInstance.application.windows.firstObject
fullSession:[self shouldReplayFullSession:replayOptions.sessionSampleRate]];
fullSession:shouldReplayFullSession];

[_notificationCenter addObserver:self
selector:@selector(pause)
Expand Down Expand Up @@ -351,6 +369,25 @@ - (void)resume
[self.sessionReplay resume];
}

- (void)start
{
if (self.sessionReplay != nil) {
if (self.sessionReplay.isFullSession == NO) {
[self.sessionReplay captureReplay];
}
return;
}

_startedAsFullSession = YES;
[self runReplayForAvailableWindow];
}

- (void)stop
{
[self.sessionReplay pause];
self.sessionReplay = nil;
}

- (void)sentrySessionEnded:(SentrySession *)session
{
[self pause];
Expand Down Expand Up @@ -395,10 +432,6 @@ - (void)uninstall
[SentrySDK.currentHub unregisterSessionListener:self];
_touchTracker = nil;
[self pause];

if (_installedInstance == self) {
_installedInstance = nil;
}
}

- (void)dealloc
Expand Down
9 changes: 5 additions & 4 deletions Sources/Sentry/include/SentrySessionReplayIntegration.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@ NS_ASSUME_NONNULL_BEGIN

@interface SentrySessionReplayIntegration : SentryBaseIntegration

/**
* The last instance of the installed integration
*/
@property (class, nonatomic, readonly, nullable) SentrySessionReplayIntegration *installed;
- (instancetype)initForManualUse:(nonnull SentryOptions *)options;

/**
* Captures Replay. Used by the Hybrid SDKs.
Expand All @@ -32,6 +29,10 @@ NS_ASSUME_NONNULL_BEGIN

- (void)resume;

- (void)stop;

- (void)start;

@end
#endif // SENTRY_TARGET_REPLAY_SUPPORTED
NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class SentrySessionReplay: NSObject {
videoSegmentStart = nil
displayLink.link(withTarget: self, selector: #selector(newFrame(_:)))
}

func captureReplayFor(event: Event) {
guard isRunning else { return }

Expand Down
6 changes: 6 additions & 0 deletions Sources/Swift/Tools/SentryViewPhotographer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,24 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider {
dispatchQueue.dispatchAsync {
let screenshot = UIGraphicsImageRenderer(size: imageSize, format: .init(for: .init(displayScale: 1))).image { context in

let imageRect = CGRect(origin: .zero, size: imageSize)
context.cgContext.addRect(CGRect(origin: CGPoint.zero, size: imageSize))
context.cgContext.clip(using: .evenOdd)
UIColor.blue.setStroke()

context.cgContext.interpolationQuality = .none
image.draw(at: .zero)

var latestRegion: RedactRegion?
for region in redact {
let rect = CGRect(origin: CGPoint.zero, size: region.size)
var transform = region.transform
let path = CGPath(rect: rect, transform: &transform)

defer { latestRegion = region }

guard latestRegion?.canReplace(as: region) != true && imageRect.intersects(path.boundingBoxOfPath) else { continue }

switch region.type {
case .redact, .redactSwiftUI:
(region.color ?? UIImageHelper.averageColor(of: context.currentImage, at: rect.applying(region.transform))).setFill()
Expand Down
6 changes: 5 additions & 1 deletion Sources/Swift/Tools/UIRedactBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ struct RedactRegion {
self.type = type
self.color = color
}

func canReplace(as other: RedactRegion) -> Bool {
size == other.size && transform == other.transform && type == other.type
}
}

class UIRedactBuilder {
Expand Down Expand Up @@ -170,7 +174,7 @@ class UIRedactBuilder {
}

//The swiftUI type needs to appear first in the list so it always get masked
return swiftUIRedact + otherRegions.reversed()
return (otherRegions + swiftUIRedact).reversed()
}

private func shouldIgnore(view: UIView) -> Bool {
Expand Down
Loading

0 comments on commit 2d35ccb

Please sign in to comment.