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

chore: replay screenshot #142

Merged
merged 10 commits into from
Jun 11, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- chore: change host to new address ([#139](https://github.com/PostHog/posthog-ios/pull/139))
- fix: rename groupProperties to groups for capture methods ([#140](https://github.com/PostHog/posthog-ios/pull/140))
- recording: add `screenshot` option for session replay instead of wireframe ([#142](https://github.com/PostHog/posthog-android/pull/142))

## 3.4.0 - 2024-05-23

Expand Down
5 changes: 5 additions & 0 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ class PostHogApi {

do {
data = try JSONSerialization.data(withJSONObject: toSend)
// remove it only for debugging
// if let newData = data {
// let convertedString = String(data: newData, encoding: .utf8)
// hedgeLog("snapshot body: \(convertedString ?? "")")
// }
} catch {
hedgeLog("Error parsing the snapshot body: \(error)")
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
Expand Down
3 changes: 3 additions & 0 deletions PostHog/PostHogQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ class PostHogQueue {
shouldRetry = true
}

// TODO: https://github.com/PostHog/posthog-android/pull/130
// fix: reduce batch size if API returns 413

if shouldRetry {
retryCount += 1
let delay = min(retryCount * retryDelay, maxRetryDelay)
Expand Down
30 changes: 15 additions & 15 deletions PostHog/PostHogSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,21 +189,6 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30
properties["$groups"] = groups!
}

var theSessionId: String?
sessionLock.withLock {
theSessionId = sessionId
}
if let theSessionId = theSessionId {
properties["$session_id"] = theSessionId
// Session replay requires $window_id, so we set as the same as $session_id.
// the backend might fallback to $session_id if $window_id is not present next.
#if os(iOS)
if config.sessionReplay {
properties["$window_id"] = theSessionId
}
#endif
}

guard let flags = featureFlags?.getFeatureFlags() as? [String: Any] else {
return properties
}
Expand Down Expand Up @@ -267,6 +252,21 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30
}
}

var theSessionId: String?
sessionLock.withLock {
theSessionId = sessionId
}
if let theSessionId = theSessionId {
props["$session_id"] = theSessionId
// Session replay requires $window_id, so we set as the same as $session_id.
// the backend might fallback to $session_id if $window_id is not present next.
#if os(iOS)
if config.sessionReplay {
props["$window_id"] = theSessionId
}
#endif
}

// Replay needs distinct_id also in the props
// remove after https://github.com/PostHog/posthog/pull/18954 gets merged
let propDistinctId = props["distinct_id"] as? String
Expand Down
39 changes: 34 additions & 5 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@
windowViews.setObject(snapshotStatus, forKey: view)
}

// TODO: IncrementalSnapshot, type=2
Copy link
Member Author

Choose a reason for hiding this comment

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

probably not needed if we make the screenshot the default option since it is always a single image anyway, unless at some point we try to optimize the data transfer by breaking a full image in tiles and only sending the ones that have changed.


var wireframes: [Any] = []
wireframes.append(wireframe.toDict())
let initialOffset = ["top": 0, "left": 0]
Expand Down Expand Up @@ -141,6 +143,15 @@
wireframe.height = Int(view.frame.size.height)
let style = RRStyle()

// no parent id means its the root
if parentId == nil, config.sessionReplayConfig.screenshot {
if let image = view.toImage() {
wireframe.base64 = imageToBase64(image)
}
wireframe.type = "screenshot"
return wireframe
}

if let textView = view as? UITextView {
wireframe.type = "text"
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textView.isNoCapture() || textView.isSensitiveText()
Expand Down Expand Up @@ -190,8 +201,9 @@
if let image = view as? UIImageView {
wireframe.type = "image"
if !image.isNoCapture(), !config.sessionReplayConfig.maskAllImages {
// TODO: check png quality
wireframe.base64 = image.image?.pngData()?.base64EncodedString()
if let image = image.image {
wireframe.base64 = imageToBase64(image)
}
}
}

Expand Down Expand Up @@ -274,6 +286,9 @@
}

@objc private func snapshot() {
// TODO: add debouncer with debouncerDelayMs to take into account how long it takes to execute the
// snapshot method
Comment on lines +289 to +290
Copy link
Member Author

Choose a reason for hiding this comment

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

since you cannot offload this from the main thread, a debouncer mechanism would be handy, can be separated to PR to get this out of the door since the timer calls snapshot every 500ms already instead of for every frame


if !PostHogSDK.shared.isSessionReplayActive() {
return
}
Expand All @@ -289,17 +304,31 @@

var screenName: String?
if let controller = window.rootViewController {
if controller is AnyObjectUIHostingViewController {
hedgeLog("SwiftUI snapshot not supported.")
// SwiftUI only supported with screenshot
if controller is AnyObjectUIHostingViewController, !config.sessionReplayConfig.screenshot {
hedgeLog("SwiftUI snapshot not supported, enable screenshot mode.")
return
// screen name only makes sense if we are not using SwiftUI
} else if !config.sessionReplayConfig.screenshot {
screenName = UIViewController.getViewControllerName(controller)
}
screenName = UIViewController.getViewControllerName(controller)
}

// this cannot run off of the main thread because most properties require to be called within the main thread
// this method has to be fast and do as little as possible
generateSnapshot(window, screenName)
}

private func imageToBase64(_ image: UIImage) -> String? {
let jpegData = image.jpegData(compressionQuality: 0.3)
let base64 = jpegData?.base64EncodedString()

if let base64 = base64 {
return "data:image/jpeg;base64,\(base64)"
}

return nil
}
}

private protocol AnyObjectUIHostingViewController: AnyObject {}
Expand Down
7 changes: 7 additions & 0 deletions PostHog/Replay/PostHogSessionReplayConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@
/// Default: true
@objc public var captureNetworkTelemetry: Bool = true

/// By default Session replay will capture all the views on the screen as a wireframe,
/// By enabling this option, PostHog will capture the screenshot of the screen.
/// The screenshot may contain sensitive information, use with caution.
/// Experimental support
/// Default: false
@objc public var screenshot: Bool = false

// TODO: sessionRecording config such as networkPayloadCapture, captureConsoleLogs, sampleRate, etc
}
#endif
2 changes: 1 addition & 1 deletion PostHog/Replay/RRWireframe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class RRWireframe {
var width: Int = 0
var height: Int = 0
var childWireframes: [RRWireframe]?
var type: String? // text|image|rectangle|input|div
var type: String? // text|image|rectangle|input|div|screenshot
var inputType: String?
var text: String?
var label: String?
Expand Down
20 changes: 20 additions & 0 deletions PostHog/Replay/UIView+Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,25 @@

return false
}

func toImage() -> UIImage? {
// Begin image context
UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, 0.0)

// Render the view's layer into the current context
guard let context = UIGraphicsGetCurrentContext() else {
UIGraphicsEndImageContext()
return UIImage()
}
layer.render(in: context)

// Capture the image from the current context
let image = UIGraphicsGetImageFromCurrentImageContext()

// End the image context
UIGraphicsEndImageContext()

return image
}
}
#endif
14 changes: 7 additions & 7 deletions PostHogExample/Api.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ class Api: ObservableObject {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "main")

func listBeers(completion _: @escaping ([PostHogBeerInfo]) -> Void) {
guard let url = URL(string: "https://api.punkapi.com/v2/beers") else {
return
}

logger.info("Requesting beers list...")
URLSession.shared.dataTask(with: url) { _, _, _ in
// guard let url = URL(string: "https://api.punkapi.com/v2/beers") else {
Copy link
Member Author

Choose a reason for hiding this comment

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

this API does not exist anymore, the sample has to be changed

// return
// }
//
// logger.info("Requesting beers list...")
// URLSession.shared.dataTask(with: url) { _, _, _ in
// let beers = try! JSONDecoder().decode([PostHogBeerInfo].self, from: data!)
//
// DispatchQueue.main.async {
// completion(beers)
// }
}.resume()
// }.resume()
}

func failingRequest() -> URLSessionDataTask? {
Expand Down
3 changes: 2 additions & 1 deletion PostHogExample/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
let config = PostHogConfig(
apiKey: "phc_pQ70jJhZKHRvDIL5ruOErnPy6xiAiWCqlL4ayELj4X8"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)
// the ScreenViews for SwiftUI does not work, the names are not useful
config.captureScreenViews = false
Expand All @@ -22,6 +22,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
config.debug = true
config.sendFeatureFlagEvent = false
config.sessionReplay = true
config.sessionReplayConfig.screenshot = true

PostHogSDK.shared.setup(config)
PostHogSDK.shared.debug()
Expand Down
2 changes: 1 addition & 1 deletion PostHogExampleMacOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PostHog
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_: Notification) {
let config = PostHogConfig(
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)
config.debug = true

Expand Down
2 changes: 1 addition & 1 deletion PostHogExampleStoryboard/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
let config = PostHogConfig(
apiKey: "phc_pQ70jJhZKHRvDIL5ruOErnPy6xiAiWCqlL4ayELj4X8"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)
// the ScreenViews for SwiftUI does not work, the names are not useful
config.captureScreenViews = false
Expand Down
2 changes: 1 addition & 1 deletion PostHogExampleTvOS/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let config = PostHogConfig(
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)
config.debug = true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct PostHogExampleWatchOSApp: App {
init() {
// TODO: init on app delegate instead
let config = PostHogConfig(
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)

PostHogSDK.shared.setup(config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
object: nil)

let config = PostHogConfig(
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)

PostHogSDK.shared.setup(config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
object: nil)

let config = PostHogConfig(
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
)

PostHogSDK.shared.setup(config)
Expand Down
6 changes: 5 additions & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,18 @@ config.sessionReplay = true
config.sessionReplayConfig.maskAllTextInputs = true
config.sessionReplayConfig.maskAllImages = true
config.sessionReplayConfig.captureNetworkTelemetry = true
// screenshot is disabled by default
// The screenshot may contain sensitive information, use with caution
config.sessionReplayConfig.screenshot = true
```

If you don't want to mask everything, you can disable the mask config above and mask specific views using the `ph-no-capture` [accessibilityIdentifier](https://developer.apple.com/documentation/uikit/uiaccessibilityidentification/1623132-accessibilityidentifier).

### Limitations

- Not compatible with [SwiftUI](https://developer.apple.com/xcode/swiftui/) yet.
- [SwiftUI](https://developer.apple.com/xcode/swiftui/) is only supported if the `screenshot` option is enabled.
- It's a representation of the user's screen, not a video recording nor a screenshot.
- Custom views are not fully supported.
- If the option `screenshot` is enabled, the SDK will take a screenshot of the screen instead of making a representation of the user's screen.
- WebView is not supported, a placeholder will be shown.
- React Native and Flutter for iOS aren't supported.
Loading