Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Session replay masking preview for SwiftUI #4737

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- Add protocol for custom screenName for UIViewControllers (#4646)
- Allow hybrid SDK to set replay options tags information (#4710)
- Add threshold to always log fatal logs (#4707)
- Session replay masking preview for SwiftUI (#4737)

### Internal

Expand Down
7 changes: 5 additions & 2 deletions Samples/iOS-SwiftUI/iOS-SwiftUI/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ struct ContentView: View {

return DataBag.shared.info["lastSpan"] as? Span
}

var body: some View {
return SentryTracedView("Content View Body", waitForFullDisplay: true) {
NavigationView {
Expand Down Expand Up @@ -235,9 +235,10 @@ struct ContentView: View {
.background(Color.white)
}
SecondView()

Text(TTDInfo)
.accessibilityIdentifier("TTDInfo")

}
}
}
Expand All @@ -255,5 +256,7 @@ struct SecondView: View {
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.sentryReplayPreviewMask(opacity: 0.3)

}
}
20 changes: 20 additions & 0 deletions Sentry.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,9 @@
D867063F27C3BC2400048851 /* SentryCoreDataTracker.h in Headers */ = {isa = PBXBuildFile; fileRef = D867063C27C3BC2400048851 /* SentryCoreDataTracker.h */; };
D86B6835294348A400B8B1FC /* SentryAttachment+Private.h in Headers */ = {isa = PBXBuildFile; fileRef = D86B6834294348A400B8B1FC /* SentryAttachment+Private.h */; };
D86F419827C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */; };
D8709AC42D3E9C63006C491E /* SentryReplayMaskPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8709AC32D3E9C5C006C491E /* SentryReplayMaskPreview.swift */; };
D8709ACB2D3F848E006C491E /* SentryReplayMaskPreviewUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8709ACA2D3F8480006C491E /* SentryReplayMaskPreviewUIView.swift */; };
D8709ACD2D3F84CF006C491E /* PreviewRedactOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8709ACC2D3F84C9006C491E /* PreviewRedactOptions.swift */; };
D8739CF32BECF70F007D2F66 /* SentryLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8739CF22BECF70F007D2F66 /* SentryLevel.swift */; };
D8739CF92BECFFB5007D2F66 /* SentryTransactionNameSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8739CF82BECFFB5007D2F66 /* SentryTransactionNameSource.swift */; };
D8739D142BEE5049007D2F66 /* SentryRRWebSpanEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8739D132BEE5049007D2F66 /* SentryRRWebSpanEvent.swift */; };
Expand Down Expand Up @@ -1984,6 +1987,9 @@
D86B6820293F39E000B8B1FC /* TestSentryViewHierarchy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TestSentryViewHierarchy.h; sourceTree = "<group>"; };
D86B6834294348A400B8B1FC /* SentryAttachment+Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "SentryAttachment+Private.h"; path = "include/SentryAttachment+Private.h"; sourceTree = "<group>"; };
D86F419727C8FEFA00490520 /* SentryCoreDataTrackerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCoreDataTrackerExtension.swift; sourceTree = "<group>"; };
D8709AC32D3E9C5C006C491E /* SentryReplayMaskPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayMaskPreview.swift; sourceTree = "<group>"; };
D8709ACA2D3F8480006C491E /* SentryReplayMaskPreviewUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayMaskPreviewUIView.swift; sourceTree = "<group>"; };
D8709ACC2D3F84C9006C491E /* PreviewRedactOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewRedactOptions.swift; sourceTree = "<group>"; };
D8739CF22BECF70F007D2F66 /* SentryLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLevel.swift; sourceTree = "<group>"; };
D8739CF82BECFFB5007D2F66 /* SentryTransactionNameSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTransactionNameSource.swift; sourceTree = "<group>"; };
D8739D132BEE5049007D2F66 /* SentryRRWebSpanEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRRWebSpanEvent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3779,6 +3785,7 @@
isa = PBXGroup;
children = (
D8199DB429376ECC0074249E /* SentryInternal */,
D8709AC92D3F83A6006C491E /* Preview */,
D8199DB529376ECC0074249E /* SentrySwiftUI.h */,
D88D25E92B8E0BAC0073C3D5 /* module.modulemap */,
D8199DB629376ECC0074249E /* SentryTracedView.swift */,
Expand Down Expand Up @@ -3876,6 +3883,16 @@
name = CoreData;
sourceTree = "<group>";
};
D8709AC92D3F83A6006C491E /* Preview */ = {
isa = PBXGroup;
children = (
D8709ACA2D3F8480006C491E /* SentryReplayMaskPreviewUIView.swift */,
D8709AC32D3E9C5C006C491E /* SentryReplayMaskPreview.swift */,
D8709ACC2D3F84C9006C491E /* PreviewRedactOptions.swift */,
);
path = Preview;
sourceTree = "<group>";
};
D8739CF62BECFF86007D2F66 /* Log */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -5313,7 +5330,10 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D8709AC42D3E9C63006C491E /* SentryReplayMaskPreview.swift in Sources */,
D8199DC129376EEC0074249E /* SentryTracedView.swift in Sources */,
D8709ACD2D3F84CF006C491E /* PreviewRedactOptions.swift in Sources */,
D8709ACB2D3F848E006C491E /* SentryReplayMaskPreviewUIView.swift in Sources */,
D48E8B9D2D3E82AC0032E35E /* SentrySpanOperation.swift in Sources */,
D8199DBF29376EE20074249E /* SentryInternal.m in Sources */,
D48E8B8B2D3E79610032E35E /* SentryTraceOrigin.swift in Sources */,
Expand Down
4 changes: 3 additions & 1 deletion Sources/Sentry/SentrySDK.m
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,16 @@ + (void)setStartTimestamp:(NSDate *)value

+ (void)startWithOptions:(SentryOptions *)options
{
// We save the options before checking for xcode preview because
brustolin marked this conversation as resolved.
Show resolved Hide resolved
// we will use this options in the preview
startOption = options;
brustolin marked this conversation as resolved.
Show resolved Hide resolved
if ([SentryDependencyContainer.sharedInstance.processInfoWrapper
.environment[SENTRY_XCODE_PREVIEW_ENVIRONMENT_KEY] isEqualToString:@"1"]) {
// Using NSLog because SentryLog was not initialized yet.
NSLog(@"[SENTRY] [WARNING] SentrySDK not started. Running from Xcode preview.");
return;
}

startOption = options;
[SentryLog configure:options.debug diagnosticLevel:options.diagnosticLevel];

// We accept the tradeoff that the SDK might not be fully initialized directly after
Expand Down
18 changes: 18 additions & 0 deletions Sources/SentrySwiftUI/Preview/PreviewRedactOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#if canImport(SwiftUI) && canImport(UIKit) && os(iOS) || os(tvOS)
import Sentry

public class PreviewRedactOptions: SentryRedactOptions {
public let maskAllText: Bool
public let maskAllImages: Bool
public let maskedViewClasses: [AnyClass]
public let unmaskedViewClasses: [AnyClass]

public init(maskAllText: Bool = true, maskAllImages: Bool = true, maskedViewClasses: [AnyClass] = [], unmaskedViewClasses: [AnyClass] = []) {
self.maskAllText = maskAllText
self.maskAllImages = maskAllImages
self.maskedViewClasses = maskedViewClasses
self.unmaskedViewClasses = unmaskedViewClasses
}
}

#endif
43 changes: 43 additions & 0 deletions Sources/SentrySwiftUI/Preview/SentryReplayMaskPreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#if canImport(SwiftUI) && canImport(UIKit) && os(iOS) || os(tvOS)
import Sentry
import SwiftUI
import UIKit

#if CARTHAGE || SWIFT_PACKAGE
@_implementationOnly import SentryInternal
#endif

@available(iOS 13, macOS 10.15, tvOS 13, *)
struct SentryReplayMaskPreview: ViewModifier {
let redactOptions: SentryRedactOptions
let opacity: Float
func body(content: Content) -> some View {
content.overlay(SentryReplayPreviewView(redactOptions: redactOptions, opacity: opacity))
}
}

@available(iOS 13, macOS 10.15, tvOS 13, *)
public extension View {
func sentryReplayPreviewMask(redactOptions: SentryRedactOptions? = nil, opacity: Float = 1) -> some View {
let options = redactOptions ?? SentrySDK.options?.sessionReplay ?? PreviewRedactOptions()
Comment on lines +21 to +22
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: What's the use case for passing different redactOptions than the ones from SentrySDK.options?.sessionReplay?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s up to the user to decide how they will use this. They might try different configurations directly from the preview without changing the project options.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK that makes sense. Thanks.

return modifier(SentryReplayMaskPreview(redactOptions: options, opacity: opacity))
}
}

@available(iOS 13, macOS 10.15, tvOS 13, *)
struct SentryReplayPreviewView: UIViewRepresentable {
let redactOptions: SentryRedactOptions
let opacity: Float

func makeUIView(context: Context) -> UIView {
let view = SentryReplayMaskPreviewUIView(redactOptions: redactOptions)
view.isUserInteractionEnabled = false
brustolin marked this conversation as resolved.
Show resolved Hide resolved
return view
}

func updateUIView(_ uiView: UIView, context: Context) {
(uiView as? SentryReplayMaskPreviewUIView)?.opacity = opacity
}
}
brustolin marked this conversation as resolved.
Show resolved Hide resolved

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#if canImport(SwiftUI) && canImport(UIKit) && os(iOS) || os(tvOS)
import Sentry
import SwiftUI
import UIKit

#if CARTHAGE || SWIFT_PACKAGE
@_implementationOnly import SentryInternal
#endif

class SentryReplayMaskPreviewUIView: UIView {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: We can move this to the main Sentry package as it has no dependencies on SwiftUI. @brustolin I think we could also use this for non SwiftUI apps. We could port this so people can use this with an option for any iOS app while debugging. With that we could also keep the SentryViewPhotographer internal.

Copy link
Contributor Author

@brustolin brustolin Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, not sure if we should. It think session replay should have its own lib.
Moving this to main just change what needs to be public, because this would need to be public in order for SentrySwiftUI to use it.

Lets go step-by-step.
After finishing this PR for SwiftUI we can debate debugging it for UIKit that has no preview.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Step-By-Step.

What stopped me from approving the PR is that we make the SentryViewPhotographer public so we can use it here. If we could already agree that it makes sense to have a public SentryReplayMaskPreviewUIView that users can also use for UIKit, we could align on the API of SentryReplayMaskPreviewUIView and keep SentryViewPhotographer. With that approach we cover more use cases and avoid making things public that aren't meant to be public. We can also discuss this on a call if we go back and forth.

private let photographer: SentryViewPhotographer
private var displayLink: CADisplayLink?
private var imageView = UIImageView()

var opacity: Float {
get { return Float(imageView.alpha) }
set { imageView.alpha = CGFloat(newValue)}
}

init(redactOptions: SentryRedactOptions) {
self.photographer = SentryViewPhotographer(renderer: PreviewRederer(), redactOptions: redactOptions)
super.init(frame: .zero)
self.isUserInteractionEnabled = false
imageView.isUserInteractionEnabled = false
imageView.sentryReplayUnmask()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
brustolin marked this conversation as resolved.
Show resolved Hide resolved
}

override func didMoveToSuperview() {
if ProcessInfo.processInfo.environment[SENTRY_XCODE_PREVIEW_ENVIRONMENT_KEY] == "1" {
displayLink = CADisplayLink(target: self, selector: #selector(update))
displayLink?.add(to: .main, forMode: .common)
brustolin marked this conversation as resolved.
Show resolved Hide resolved
} else {
print("[SENTRY] [WARNING] SentryReplayMaskPreview is not meant to be used in your app, only with SwiftUI Previews.")
}
Comment on lines +42 to +47
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: Why limit this functionality only to previews? If somebody wants to use it when running the app on a simulator, let them. I quickly tried it, and it works. Or am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If somebody wants to use it...

This could be said for so many things.
I choose to make it harder for the user to forget this on the app by mistake.

We can discuss this approach, but so far we had zero request for it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about allowing it and logging a warning when we see it's used elsewhere?

}

@objc
private func update() {
guard let window = self.window else { return }
self.photographer.image(view: window) { image in
DispatchQueue.main.async {
self.showImage(image: image)
}
}
}

private func showImage(image: UIImage) {
guard let window = super.window else { return }
if imageView.superview != window {
window.addSubview(imageView)
}
imageView.image = image
imageView.frame = window.bounds
}
}

class PreviewRederer: ViewRenderer {
func render(view: UIView) -> UIImage {
return UIGraphicsImageRenderer(size: view.frame.size, format: .init(for: .init(displayScale: 1))).image { _ in
// Creates a transparent image of the view size that will be used to drawn the redact regions.
// Transparent background is the default, so no additional drawing is required.
// Left blank on purpose
brustolin marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

#endif
2 changes: 2 additions & 0 deletions Sources/SentrySwiftUI/SentryInternal/SentryInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

NS_ASSUME_NONNULL_BEGIN

extern NSString *const SENTRY_XCODE_PREVIEW_ENVIRONMENT_KEY;

typedef NS_ENUM(NSInteger, SentryTransactionNameSource);

@class SentrySpanId;
Expand Down
10 changes: 5 additions & 5 deletions Sources/Swift/Protocol/SentryRedactOptions.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

@objc
protocol SentryRedactOptions {
public protocol SentryRedactOptions {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: I'm a bit confused about why we need a protocol for this and two separate implementations: SentryRedactDefaultOptions and PreviewRedactOptions. Can't we ditch the protocol and have one public final class for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO protocol is the best approach, it gives a lot of flexibility and separation of concerns.

We can make SentryReplayOptions implement SentryRedactOptionsSentry without the need to have another level in the replay options.

And we can have a simpler object for screenshot redact, and then another one for preview redact, maybe with more options in there specific for preview.

var maskAllText: Bool { get }
var maskAllImages: Bool { get }
var maskedViewClasses: [AnyClass] { get }
Expand All @@ -10,8 +10,8 @@ protocol SentryRedactOptions {

@objcMembers
final class SentryRedactDefaultOptions: NSObject, SentryRedactOptions {
var maskAllText: Bool = true
var maskAllImages: Bool = true
var maskedViewClasses: [AnyClass] = []
var unmaskedViewClasses: [AnyClass] = []
public var maskAllText: Bool = true
public var maskAllImages: Bool = true
public var maskedViewClasses: [AnyClass] = []
public var unmaskedViewClasses: [AnyClass] = []
}
10 changes: 5 additions & 5 deletions Sources/Swift/Tools/SentryViewPhotographer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import CoreGraphics
import Foundation
import UIKit

protocol ViewRenderer {
public protocol ViewRenderer {
func render(view: UIView) -> UIImage
}

Expand All @@ -20,24 +20,24 @@ class DefaultViewRenderer: ViewRenderer {
}

@objcMembers
class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider {
public class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@philipphofmann will probably not agree with changing this to public, we should at least somehow mark this class as "internal use only"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should stop using hacky solutions and add a lot of "code smells" into the code.
If the user try to use an undocumented class, that is not part of the main product, let them try.

private let redactBuilder: UIRedactBuilder
private let dispatchQueue = SentryDispatchQueueWrapper()

var renderer: ViewRenderer

init(renderer: ViewRenderer, redactOptions: SentryRedactOptions) {
public init(renderer: ViewRenderer, redactOptions: SentryRedactOptions) {
self.renderer = renderer
redactBuilder = UIRedactBuilder(options: redactOptions)
super.init()
}

init(redactOptions: SentryRedactOptions) {
public init(redactOptions: SentryRedactOptions) {
self.renderer = DefaultViewRenderer()
self.redactBuilder = UIRedactBuilder(options: redactOptions)
}

func image(view: UIView, onComplete: @escaping ScreenshotCallback) {
public func image(view: UIView, onComplete: @escaping ScreenshotCallback) {
let redact = redactBuilder.redactRegionsFor(view: view)
let image = renderer.render(view: view)
let viewSize = view.bounds.size
Expand Down
2 changes: 1 addition & 1 deletion Sources/Swift/Tools/SentryViewScreenshotProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import Foundation
import UIKit

typealias ScreenshotCallback = (UIImage) -> Void
public typealias ScreenshotCallback = (UIImage) -> Void

@objc
protocol SentryViewScreenshotProvider: NSObjectProtocol {
Expand Down
Loading