diff --git a/CHANGELOG.md b/CHANGELOG.md index 049add5cd..4cf5b14fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 79d00c134..bf7dc6eb3 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -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)) diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index add4b846d..f822645b6 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -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) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 3635caf93..599cc9c5e 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -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 } @@ -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 diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index f4793b57b..c47548a1b 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -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] @@ -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() @@ -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) + } } } @@ -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 } @@ -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 {} diff --git a/PostHog/Replay/PostHogSessionReplayConfig.swift b/PostHog/Replay/PostHogSessionReplayConfig.swift index 316660386..e7daa1a07 100644 --- a/PostHog/Replay/PostHogSessionReplayConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConfig.swift @@ -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 diff --git a/PostHog/Replay/RRWireframe.swift b/PostHog/Replay/RRWireframe.swift index 9dbdccec2..8fd5d5409 100644 --- a/PostHog/Replay/RRWireframe.swift +++ b/PostHog/Replay/RRWireframe.swift @@ -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? diff --git a/PostHog/Replay/UIView+Util.swift b/PostHog/Replay/UIView+Util.swift index ae737ba85..79e3f06a5 100644 --- a/PostHog/Replay/UIView+Util.swift +++ b/PostHog/Replay/UIView+Util.swift @@ -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 diff --git a/PostHogExample/Api.swift b/PostHogExample/Api.swift index 5f59f4d83..3e5ad36c0 100644 --- a/PostHogExample/Api.swift +++ b/PostHogExample/Api.swift @@ -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? { diff --git a/PostHogExample/AppDelegate.swift b/PostHogExample/AppDelegate.swift index 959539b29..5df538d2f 100644 --- a/PostHogExample/AppDelegate.swift +++ b/PostHogExample/AppDelegate.swift @@ -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 @@ -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() diff --git a/PostHogExampleMacOS/AppDelegate.swift b/PostHogExampleMacOS/AppDelegate.swift index 090fad221..40ffd4adc 100644 --- a/PostHogExampleMacOS/AppDelegate.swift +++ b/PostHogExampleMacOS/AppDelegate.swift @@ -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 diff --git a/PostHogExampleStoryboard/AppDelegate.swift b/PostHogExampleStoryboard/AppDelegate.swift index 0eb19db84..2d2643363 100644 --- a/PostHogExampleStoryboard/AppDelegate.swift +++ b/PostHogExampleStoryboard/AppDelegate.swift @@ -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 diff --git a/PostHogExampleTvOS/AppDelegate.swift b/PostHogExampleTvOS/AppDelegate.swift index 5fc173348..4ff1695fd 100644 --- a/PostHogExampleTvOS/AppDelegate.swift +++ b/PostHogExampleTvOS/AppDelegate.swift @@ -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 diff --git a/PostHogExampleWatchOS/PostHogExampleWatchOS Watch App/PostHogExampleWatchOSApp.swift b/PostHogExampleWatchOS/PostHogExampleWatchOS Watch App/PostHogExampleWatchOSApp.swift index ba5067f2a..560237fed 100644 --- a/PostHogExampleWatchOS/PostHogExampleWatchOS Watch App/PostHogExampleWatchOSApp.swift +++ b/PostHogExampleWatchOS/PostHogExampleWatchOS Watch App/PostHogExampleWatchOSApp.swift @@ -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) diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift b/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift index e8c2c0cb4..bb02d640a 100644 --- a/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift +++ b/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift @@ -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) diff --git a/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift b/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift index 178f4d40f..0be450b08 100644 --- a/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift +++ b/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift @@ -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) diff --git a/USAGE.md b/USAGE.md index 70c8a2c75..6e3e730b1 100644 --- a/USAGE.md +++ b/USAGE.md @@ -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.