Skip to content

Commit dc7b837

Browse files
authored
chore: replay screenshot (#142)
1 parent 6cfe126 commit dc7b837

File tree

17 files changed

+106
-36
lines changed

17 files changed

+106
-36
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

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

67
## 3.4.0 - 2024-05-23
78

PostHog/PostHogApi.swift

+5
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ class PostHogApi {
116116

117117
do {
118118
data = try JSONSerialization.data(withJSONObject: toSend)
119+
// remove it only for debugging
120+
// if let newData = data {
121+
// let convertedString = String(data: newData, encoding: .utf8)
122+
// hedgeLog("snapshot body: \(convertedString ?? "")")
123+
// }
119124
} catch {
120125
hedgeLog("Error parsing the snapshot body: \(error)")
121126
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))

PostHog/PostHogQueue.swift

+3
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class PostHogQueue {
106106
shouldRetry = true
107107
}
108108

109+
// TODO: https://github.com/PostHog/posthog-android/pull/130
110+
// fix: reduce batch size if API returns 413
111+
109112
if shouldRetry {
110113
retryCount += 1
111114
let delay = min(retryCount * retryDelay, maxRetryDelay)

PostHog/PostHogSDK.swift

+15-15
Original file line numberDiff line numberDiff line change
@@ -189,21 +189,6 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30
189189
properties["$groups"] = groups!
190190
}
191191

192-
var theSessionId: String?
193-
sessionLock.withLock {
194-
theSessionId = sessionId
195-
}
196-
if let theSessionId = theSessionId {
197-
properties["$session_id"] = theSessionId
198-
// Session replay requires $window_id, so we set as the same as $session_id.
199-
// the backend might fallback to $session_id if $window_id is not present next.
200-
#if os(iOS)
201-
if config.sessionReplay {
202-
properties["$window_id"] = theSessionId
203-
}
204-
#endif
205-
}
206-
207192
guard let flags = featureFlags?.getFeatureFlags() as? [String: Any] else {
208193
return properties
209194
}
@@ -267,6 +252,21 @@ private let sessionChangeThreshold: TimeInterval = 60 * 30
267252
}
268253
}
269254

255+
var theSessionId: String?
256+
sessionLock.withLock {
257+
theSessionId = sessionId
258+
}
259+
if let theSessionId = theSessionId {
260+
props["$session_id"] = theSessionId
261+
// Session replay requires $window_id, so we set as the same as $session_id.
262+
// the backend might fallback to $session_id if $window_id is not present next.
263+
#if os(iOS)
264+
if config.sessionReplay {
265+
props["$window_id"] = theSessionId
266+
}
267+
#endif
268+
}
269+
270270
// Replay needs distinct_id also in the props
271271
// remove after https://github.com/PostHog/posthog/pull/18954 gets merged
272272
let propDistinctId = props["distinct_id"] as? String

PostHog/Replay/PostHogReplayIntegration.swift

+34-5
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@
9696
windowViews.setObject(snapshotStatus, forKey: view)
9797
}
9898

99+
// TODO: IncrementalSnapshot, type=2
100+
99101
var wireframes: [Any] = []
100102
wireframes.append(wireframe.toDict())
101103
let initialOffset = ["top": 0, "left": 0]
@@ -141,6 +143,15 @@
141143
wireframe.height = Int(view.frame.size.height)
142144
let style = RRStyle()
143145

146+
// no parent id means its the root
147+
if parentId == nil, config.sessionReplayConfig.screenhshotMode {
148+
if let image = view.toImage() {
149+
wireframe.base64 = imageToBase64(image)
150+
}
151+
wireframe.type = "screenshot"
152+
return wireframe
153+
}
154+
144155
if let textView = view as? UITextView {
145156
wireframe.type = "text"
146157
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textView.isNoCapture() || textView.isSensitiveText()
@@ -190,8 +201,9 @@
190201
if let image = view as? UIImageView {
191202
wireframe.type = "image"
192203
if !image.isNoCapture(), !config.sessionReplayConfig.maskAllImages {
193-
// TODO: check png quality
194-
wireframe.base64 = image.image?.pngData()?.base64EncodedString()
204+
if let image = image.image {
205+
wireframe.base64 = imageToBase64(image)
206+
}
195207
}
196208
}
197209

@@ -274,6 +286,9 @@
274286
}
275287

276288
@objc private func snapshot() {
289+
// TODO: add debouncer with debouncerDelayMs to take into account how long it takes to execute the
290+
// snapshot method
291+
277292
if !PostHogSDK.shared.isSessionReplayActive() {
278293
return
279294
}
@@ -289,17 +304,31 @@
289304

290305
var screenName: String?
291306
if let controller = window.rootViewController {
292-
if controller is AnyObjectUIHostingViewController {
293-
hedgeLog("SwiftUI snapshot not supported.")
307+
// SwiftUI only supported with screenshot
308+
if controller is AnyObjectUIHostingViewController, !config.sessionReplayConfig.screenhshotMode {
309+
hedgeLog("SwiftUI snapshot not supported, enable screenshot mode.")
294310
return
311+
// screen name only makes sense if we are not using SwiftUI
312+
} else if !config.sessionReplayConfig.screenhshotMode {
313+
screenName = UIViewController.getViewControllerName(controller)
295314
}
296-
screenName = UIViewController.getViewControllerName(controller)
297315
}
298316

299317
// this cannot run off of the main thread because most properties require to be called within the main thread
300318
// this method has to be fast and do as little as possible
301319
generateSnapshot(window, screenName)
302320
}
321+
322+
private func imageToBase64(_ image: UIImage) -> String? {
323+
let jpegData = image.jpegData(compressionQuality: 0.3)
324+
let base64 = jpegData?.base64EncodedString()
325+
326+
if let base64 = base64 {
327+
return "data:image/jpeg;base64,\(base64)"
328+
}
329+
330+
return nil
331+
}
303332
}
304333

305334
private protocol AnyObjectUIHostingViewController: AnyObject {}

PostHog/Replay/PostHogSessionReplayConfig.swift

+7
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
/// Default: true
2424
@objc public var captureNetworkTelemetry: Bool = true
2525

26+
/// By default Session replay will capture all the views on the screen as a wireframe,
27+
/// By enabling this option, PostHog will capture the screenshot of the screen.
28+
/// The screenshot may contain sensitive information, use with caution.
29+
/// Experimental support
30+
/// Default: false
31+
@objc public var screenhshotMode: Bool = false
32+
2633
// TODO: sessionRecording config such as networkPayloadCapture, captureConsoleLogs, sampleRate, etc
2734
}
2835
#endif

PostHog/Replay/RRWireframe.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class RRWireframe {
1616
var width: Int = 0
1717
var height: Int = 0
1818
var childWireframes: [RRWireframe]?
19-
var type: String? // text|image|rectangle|input|div
19+
var type: String? // text|image|rectangle|input|div|screenshot
2020
var inputType: String?
2121
var text: String?
2222
var label: String?

PostHog/Replay/UIView+Util.swift

+20
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,25 @@
2424

2525
return false
2626
}
27+
28+
func toImage() -> UIImage? {
29+
// Begin image context
30+
UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, 0.0)
31+
32+
// Render the view's layer into the current context
33+
guard let context = UIGraphicsGetCurrentContext() else {
34+
UIGraphicsEndImageContext()
35+
return UIImage()
36+
}
37+
layer.render(in: context)
38+
39+
// Capture the image from the current context
40+
let image = UIGraphicsGetImageFromCurrentImageContext()
41+
42+
// End the image context
43+
UIGraphicsEndImageContext()
44+
45+
return image
46+
}
2747
}
2848
#endif

PostHogExample/Api.swift

+7-7
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ class Api: ObservableObject {
2020
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "main")
2121

2222
func listBeers(completion _: @escaping ([PostHogBeerInfo]) -> Void) {
23-
guard let url = URL(string: "https://api.punkapi.com/v2/beers") else {
24-
return
25-
}
26-
27-
logger.info("Requesting beers list...")
28-
URLSession.shared.dataTask(with: url) { _, _, _ in
23+
// guard let url = URL(string: "https://api.punkapi.com/v2/beers") else {
24+
// return
25+
// }
26+
//
27+
// logger.info("Requesting beers list...")
28+
// URLSession.shared.dataTask(with: url) { _, _, _ in
2929
// let beers = try! JSONDecoder().decode([PostHogBeerInfo].self, from: data!)
3030
//
3131
// DispatchQueue.main.async {
3232
// completion(beers)
3333
// }
34-
}.resume()
34+
// }.resume()
3535
}
3636

3737
func failingRequest() -> URLSessionDataTask? {

PostHogExample/AppDelegate.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import UIKit
1212
class AppDelegate: NSObject, UIApplicationDelegate {
1313
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
1414
let config = PostHogConfig(
15-
apiKey: "phc_pQ70jJhZKHRvDIL5ruOErnPy6xiAiWCqlL4ayELj4X8"
15+
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
1616
)
1717
// the ScreenViews for SwiftUI does not work, the names are not useful
1818
config.captureScreenViews = false
@@ -22,6 +22,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
2222
config.debug = true
2323
config.sendFeatureFlagEvent = false
2424
config.sessionReplay = true
25+
config.sessionReplayConfig.screenhshotMode = true
2526

2627
PostHogSDK.shared.setup(config)
2728
PostHogSDK.shared.debug()

PostHogExampleMacOS/AppDelegate.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import PostHog
44
class AppDelegate: NSObject, NSApplicationDelegate {
55
func applicationDidFinishLaunching(_: Notification) {
66
let config = PostHogConfig(
7-
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
7+
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
88
)
99
config.debug = true
1010

PostHogExampleStoryboard/AppDelegate.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
1313
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
1414
// Override point for customization after application launch.
1515
let config = PostHogConfig(
16-
apiKey: "phc_pQ70jJhZKHRvDIL5ruOErnPy6xiAiWCqlL4ayELj4X8"
16+
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
1717
)
1818
// the ScreenViews for SwiftUI does not work, the names are not useful
1919
config.captureScreenViews = false

PostHogExampleTvOS/AppDelegate.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
77

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

PostHogExampleWatchOS/PostHogExampleWatchOS Watch App/PostHogExampleWatchOSApp.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ struct PostHogExampleWatchOSApp: App {
1313
init() {
1414
// TODO: init on app delegate instead
1515
let config = PostHogConfig(
16-
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
16+
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
1717
)
1818

1919
PostHogSDK.shared.setup(config)

PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
1919
object: nil)
2020

2121
let config = PostHogConfig(
22-
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
22+
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
2323
)
2424

2525
PostHogSDK.shared.setup(config)

PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
1818
object: nil)
1919

2020
let config = PostHogConfig(
21-
apiKey: "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"
21+
apiKey: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D"
2222
)
2323

2424
PostHogSDK.shared.setup(config)

USAGE.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,18 @@ config.sessionReplay = true
214214
config.sessionReplayConfig.maskAllTextInputs = true
215215
config.sessionReplayConfig.maskAllImages = true
216216
config.sessionReplayConfig.captureNetworkTelemetry = true
217+
// screenshot is disabled by default
218+
// The screenshot may contain sensitive information, use with caution
219+
config.sessionReplayConfig.screenshot = true
217220
```
218221

219222
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).
220223

221224
### Limitations
222225

223-
- Not compatible with [SwiftUI](https://developer.apple.com/xcode/swiftui/) yet.
226+
- [SwiftUI](https://developer.apple.com/xcode/swiftui/) is only supported if the `screenshot` option is enabled.
224227
- It's a representation of the user's screen, not a video recording nor a screenshot.
225228
- Custom views are not fully supported.
229+
- 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.
226230
- WebView is not supported, a placeholder will be shown.
227231
- React Native and Flutter for iOS aren't supported.

0 commit comments

Comments
 (0)