Skip to content

Commit

Permalink
chore: replay screenshot (#142)
Browse files Browse the repository at this point in the history
  • Loading branch information
marandaneto authored Jun 11, 2024
1 parent 6cfe126 commit dc7b837
Show file tree
Hide file tree
Showing 17 changed files with 106 additions and 36 deletions.
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

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.screenhshotMode {
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

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.screenhshotMode {
hedgeLog("SwiftUI snapshot not supported, enable screenshot mode.")
return
// screen name only makes sense if we are not using SwiftUI
} else if !config.sessionReplayConfig.screenhshotMode {
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 screenhshotMode: 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 {
// 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.screenhshotMode = 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.

0 comments on commit dc7b837

Please sign in to comment.