@plushanalytics/react-native-session-replay 3.0.0 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +59 -2
  2. package/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayModule.kt +125 -27
  3. package/ios/PosthogReactNativeSessionReplay.mm +3 -0
  4. package/ios/PosthogReactNativeSessionReplay.swift +187 -36
  5. package/lib/commonjs/adapters/mobileReplayAdapter.js +55 -18
  6. package/lib/commonjs/adapters/mobileReplayAdapter.js.map +1 -1
  7. package/lib/commonjs/client.js +2 -1
  8. package/lib/commonjs/client.js.map +1 -1
  9. package/lib/commonjs/nativeBridge.js +5 -0
  10. package/lib/commonjs/nativeBridge.js.map +1 -1
  11. package/lib/module/adapters/mobileReplayAdapter.js +55 -18
  12. package/lib/module/adapters/mobileReplayAdapter.js.map +1 -1
  13. package/lib/module/client.js +2 -1
  14. package/lib/module/client.js.map +1 -1
  15. package/lib/module/nativeBridge.js +4 -0
  16. package/lib/module/nativeBridge.js.map +1 -1
  17. package/lib/typescript/commonjs/src/adapters/mobileReplayAdapter.d.ts +2 -1
  18. package/lib/typescript/commonjs/src/adapters/mobileReplayAdapter.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/src/client.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/src/index.d.ts +1 -1
  21. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/src/nativeBridge.d.ts +2 -0
  23. package/lib/typescript/commonjs/src/nativeBridge.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/src/types.d.ts +16 -5
  25. package/lib/typescript/commonjs/src/types.d.ts.map +1 -1
  26. package/lib/typescript/module/src/adapters/mobileReplayAdapter.d.ts +2 -1
  27. package/lib/typescript/module/src/adapters/mobileReplayAdapter.d.ts.map +1 -1
  28. package/lib/typescript/module/src/client.d.ts.map +1 -1
  29. package/lib/typescript/module/src/index.d.ts +1 -1
  30. package/lib/typescript/module/src/index.d.ts.map +1 -1
  31. package/lib/typescript/module/src/nativeBridge.d.ts +2 -0
  32. package/lib/typescript/module/src/nativeBridge.d.ts.map +1 -1
  33. package/lib/typescript/module/src/types.d.ts +16 -5
  34. package/lib/typescript/module/src/types.d.ts.map +1 -1
  35. package/package.json +1 -1
  36. package/src/adapters/mobileReplayAdapter.ts +112 -19
  37. package/src/client.ts +2 -1
  38. package/src/index.tsx +2 -0
  39. package/src/nativeBridge.ts +8 -1
  40. package/src/types.ts +19 -5
package/README.md CHANGED
@@ -41,7 +41,17 @@ export default function App(): JSX.Element {
41
41
  config={{
42
42
  apiKey: "plsh_live_...",
43
43
  projectId: "mobile_app",
44
- context: { platform: "react-native" }
44
+ context: { platform: "react-native" },
45
+ sessionReplayConfig: {
46
+ enabled: true,
47
+ masking: {
48
+ textInputs: true,
49
+ images: true,
50
+ sandboxedViews: true,
51
+ },
52
+ captureNetworkTelemetry: true,
53
+ throttleDelayMs: 1000,
54
+ },
45
55
  }}
46
56
  >
47
57
  <CheckoutButton />
@@ -57,13 +67,60 @@ import { createPlushAnalyticsClient } from "@plushanalytics/react-native-session
57
67
 
58
68
  const analytics = createPlushAnalyticsClient({
59
69
  apiKey: "plsh_live_...",
60
- projectId: "mobile_app"
70
+ projectId: "mobile_app",
71
+ sessionReplayConfig: {
72
+ enabled: true,
73
+ masking: {
74
+ textInputs: true,
75
+ images: true,
76
+ sandboxedViews: true,
77
+ },
78
+ },
61
79
  });
62
80
 
63
81
  await analytics.track("app_opened");
64
82
  await analytics.shutdown();
65
83
  ```
66
84
 
85
+ ## Session Replay Config
86
+
87
+ Replay behavior is configured locally in the SDK. It does not depend on backend remote config.
88
+
89
+ - `config.sessionReplayConfig.enabled`: defaults to `true`
90
+ - `config.sessionReplayConfig.masking.textInputs`: defaults to `true`
91
+ - `config.sessionReplayConfig.masking.images`: defaults to `true`
92
+ - `config.sessionReplayConfig.masking.sandboxedViews`: defaults to `true`
93
+ - `config.sessionReplayConfig.captureLog`: defaults to `true`
94
+ - `config.sessionReplayConfig.captureNetworkTelemetry`: defaults to `true`
95
+ - `config.sessionReplayConfig.throttleDelayMs`: defaults to `1000`
96
+
97
+ Per-session overrides are supported via `startSessionRecording({ replayConfig })` / `startReplay({ replayConfig })`.
98
+
99
+ ```ts
100
+ await analytics.startReplay({
101
+ replayConfig: {
102
+ enabled: true,
103
+ masking: {
104
+ images: false,
105
+ },
106
+ },
107
+ });
108
+ ```
109
+
110
+ Legacy flat replay keys are still supported in per-session overrides for backward compatibility:
111
+ `maskAllTextInputs`, `maskAllImages`, `maskAllSandboxedViews`.
112
+
113
+ ## Replay Authentication Headers
114
+
115
+ Replay uploads include these headers from the configured `apiKey`:
116
+
117
+ - `x-api-key`
118
+ - `x-plush-api-key`
119
+ - `x-plushanalytics-key`
120
+ - `Authorization: Bearer <apiKey>`
121
+
122
+ Headers are injected natively on outgoing replay batch requests so they remain present even if underlying SDK session defaults are rewritten.
123
+
67
124
  ## Common Usage
68
125
 
69
126
  ```ts
@@ -15,7 +15,10 @@ import com.posthog.internal.PostHogPreferences
15
15
  import com.posthog.internal.PostHogPreferences.Companion.ANONYMOUS_ID
16
16
  import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID
17
17
  import com.posthog.internal.PostHogSessionManager
18
+ import java.net.URI
18
19
  import java.util.UUID
20
+ import okhttp3.Interceptor
21
+ import okhttp3.OkHttpClient
19
22
 
20
23
  class PosthogReactNativeSessionReplayModule(
21
24
  reactContext: ReactApplicationContext,
@@ -39,13 +42,24 @@ class PosthogReactNativeSessionReplayModule(
39
42
  val context = this.reactApplicationContext
40
43
  val apiKey = runCatching { sdkOptions.getString("apiKey") }.getOrNull() ?: ""
41
44
  val host = runCatching { sdkOptions.getString("host") }.getOrNull() ?: PostHogConfig.DEFAULT_HOST
45
+ if (isPosthogHost(host)) {
46
+ val message = "Blocked replay host '$host' because posthog.com hosts are not allowed."
47
+ logError("start", IllegalArgumentException(message))
48
+ promise.reject("PLUSH_BLOCKED_REPLAY_HOST", message)
49
+ return@Runnable
50
+ }
51
+
42
52
  val debugValue = runCatching { sdkOptions.getBoolean("debug") }.getOrNull() ?: false
43
53
 
44
- val maskAllTextInputs = runCatching { sdkReplayConfig.getBoolean("maskAllTextInputs") }.getOrNull() ?: DEFAULT_MASK_ALL_TEXT_INPUTS
45
- val maskAllImages = runCatching { sdkReplayConfig.getBoolean("maskAllImages") }.getOrNull() ?: DEFAULT_MASK_ALL_IMAGES
46
- val captureLog = runCatching { sdkReplayConfig.getBoolean("captureLog") }.getOrNull() ?: DEFAULT_CAPTURE_LOG
54
+ val maskAllTextInputs =
55
+ runCatching { sdkReplayConfig.getBoolean("maskAllTextInputs") }.getOrNull()
56
+ ?: DEFAULT_MASK_ALL_TEXT_INPUTS
57
+ val maskAllImages =
58
+ runCatching { sdkReplayConfig.getBoolean("maskAllImages") }.getOrNull() ?: DEFAULT_MASK_ALL_IMAGES
59
+ val captureLog =
60
+ runCatching { sdkReplayConfig.getBoolean("captureLog") }.getOrNull() ?: DEFAULT_CAPTURE_LOG
47
61
 
48
- // read throttleDelayMs and use androidDebouncerDelayMs as a fallback for back compatibility
62
+ // Read throttleDelayMs and use androidDebouncerDelayMs as a fallback for back compatibility.
49
63
  val throttleDelayMs =
50
64
  if (sdkReplayConfig.hasKey("throttleDelayMs")) {
51
65
  sdkReplayConfig.getInt("throttleDelayMs")
@@ -56,24 +70,10 @@ class PosthogReactNativeSessionReplayModule(
56
70
  }
57
71
 
58
72
  val endpoint = decideReplayConfig.getString("endpoint")
59
-
60
- val distinctId =
61
- try {
62
- sdkOptions.getString("distinctId") ?: ""
63
- } catch (e: Throwable) {
64
- logError("parse distinctId", e)
65
- ""
66
- }
67
- val anonymousId =
68
- try {
69
- sdkOptions.getString("anonymousId") ?: ""
70
- } catch (e: Throwable) {
71
- logError("parse anonymousId", e)
72
- ""
73
- }
74
- val theSdkVersion = runCatching { sdkOptions.getString("sdkVersion") }.getOrNull()
75
-
76
- val theFlushAt = runCatching { sdkOptions.getInt("flushAt") }.getOrNull() ?: DEFAULT_FLUSH_AT
73
+ val distinctId = runCatching { sdkOptions.getString("distinctId") }.getOrNull() ?: ""
74
+ val anonymousId = runCatching { sdkOptions.getString("anonymousId") }.getOrNull() ?: ""
75
+ val sdkVersion = runCatching { sdkOptions.getString("sdkVersion") }.getOrNull()
76
+ val flushAt = runCatching { sdkOptions.getInt("flushAt") }.getOrNull() ?: DEFAULT_FLUSH_AT
77
77
 
78
78
  val config =
79
79
  PostHogAndroidConfig(apiKey, host).apply {
@@ -81,25 +81,29 @@ class PosthogReactNativeSessionReplayModule(
81
81
  captureDeepLinks = false
82
82
  captureApplicationLifecycleEvents = false
83
83
  captureScreenViews = false
84
- flushAt = theFlushAt
84
+ preloadFeatureFlags = false
85
+ remoteConfig = false
86
+ this.flushAt = flushAt
85
87
  sessionReplay = true
86
88
  sessionReplayConfig.screenshot = true
87
89
  sessionReplayConfig.captureLogcat = captureLog
88
90
  sessionReplayConfig.throttleDelayMs = throttleDelayMs.toLong()
89
91
  sessionReplayConfig.maskAllImages = maskAllImages
90
92
  sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
93
+ httpClient = buildApiKeyClient(apiKey)
91
94
 
92
95
  if (!endpoint.isNullOrEmpty()) {
93
96
  snapshotEndpoint = endpoint
94
97
  }
95
98
 
96
- if (!theSdkVersion.isNullOrEmpty()) {
99
+ if (!sdkVersion.isNullOrEmpty()) {
97
100
  sdkName = "posthog-react-native"
98
- sdkVersion = theSdkVersion
101
+ this.sdkVersion = sdkVersion
99
102
  }
100
103
  }
101
- PostHogAndroid.setup(context, config)
102
104
 
105
+ PostHogAndroid.setup(context, config)
106
+ ensureReplayClientsAuthHeaders(apiKey)
103
107
  setIdentify(config.cachePreferences, distinctId, anonymousId)
104
108
  } catch (e: Throwable) {
105
109
  logError("start", e)
@@ -108,7 +112,7 @@ class PosthogReactNativeSessionReplayModule(
108
112
  }
109
113
  }
110
114
 
111
- // forces the SDK to be initialized on the main thread
115
+ // Force the SDK to be initialized on the main thread.
112
116
  if (UiThreadUtil.isOnUiThread()) {
113
117
  initRunnable.run()
114
118
  } else {
@@ -125,6 +129,7 @@ class PosthogReactNativeSessionReplayModule(
125
129
  val uuid = UUID.fromString(sessionId)
126
130
  PostHogSessionManager.setSessionId(uuid)
127
131
  PostHog.startSession()
132
+ PostHog.startSessionReplay(false)
128
133
  } catch (e: Throwable) {
129
134
  logError("startSession", e)
130
135
  } finally {
@@ -153,6 +158,17 @@ class PosthogReactNativeSessionReplayModule(
153
158
  }
154
159
  }
155
160
 
161
+ @ReactMethod
162
+ fun flush(promise: Promise) {
163
+ try {
164
+ PostHog.flush()
165
+ } catch (e: Throwable) {
166
+ logError("flush", e)
167
+ } finally {
168
+ promise.resolve(null)
169
+ }
170
+ }
171
+
156
172
  @ReactMethod
157
173
  fun identify(
158
174
  distinctId: String,
@@ -183,6 +199,77 @@ class PosthogReactNativeSessionReplayModule(
183
199
  }
184
200
  }
185
201
 
202
+ private fun buildApiKeyClient(apiKey: String): OkHttpClient {
203
+ return OkHttpClient.Builder()
204
+ .addInterceptor(
205
+ Interceptor { chain ->
206
+ val original = chain.request()
207
+ val updated =
208
+ original
209
+ .newBuilder()
210
+ .header("x-api-key", apiKey)
211
+ .header("x-plush-api-key", apiKey)
212
+ .header("x-plushanalytics-key", apiKey)
213
+ .header("authorization", "Bearer $apiKey")
214
+ .build()
215
+ chain.proceed(updated)
216
+ }
217
+ ).build()
218
+ }
219
+
220
+ private fun ensureReplayClientsAuthHeaders(apiKey: String) {
221
+ try {
222
+ val sharedMethod = PostHog::class.java.getDeclaredMethod("access\$getShared\$cp")
223
+ sharedMethod.isAccessible = true
224
+ val sharedInstance = sharedMethod.invoke(null) ?: return
225
+
226
+ patchQueueApiClient(sharedInstance, "queue", apiKey)
227
+ patchQueueApiClient(sharedInstance, "replayQueue", apiKey)
228
+ } catch (e: Throwable) {
229
+ logError("ensureReplayClientsAuthHeaders", e)
230
+ }
231
+ }
232
+
233
+ private fun patchQueueApiClient(
234
+ sharedInstance: Any,
235
+ queueFieldName: String,
236
+ apiKey: String,
237
+ ) {
238
+ try {
239
+ val queueField = sharedInstance.javaClass.getDeclaredField(queueFieldName)
240
+ queueField.isAccessible = true
241
+ val queue = queueField.get(sharedInstance) ?: return
242
+
243
+ val apiField = queue.javaClass.getDeclaredField("api")
244
+ apiField.isAccessible = true
245
+ val api = apiField.get(queue) ?: return
246
+
247
+ val clientField = api.javaClass.getDeclaredField("client")
248
+ clientField.isAccessible = true
249
+ val originalClient = clientField.get(api) as? OkHttpClient ?: return
250
+
251
+ val patchedClient =
252
+ originalClient.newBuilder()
253
+ .addInterceptor(
254
+ Interceptor { chain ->
255
+ val updatedRequest =
256
+ chain.request()
257
+ .newBuilder()
258
+ .header("x-api-key", apiKey)
259
+ .header("x-plush-api-key", apiKey)
260
+ .header("x-plushanalytics-key", apiKey)
261
+ .header("authorization", "Bearer $apiKey")
262
+ .build()
263
+ chain.proceed(updatedRequest)
264
+ }
265
+ ).build()
266
+
267
+ clientField.set(api, patchedClient)
268
+ } catch (e: Throwable) {
269
+ logError("patchQueueApiClient", e)
270
+ }
271
+ }
272
+
186
273
  private fun logError(
187
274
  method: String,
188
275
  error: Throwable,
@@ -190,6 +277,17 @@ class PosthogReactNativeSessionReplayModule(
190
277
  Log.println(Log.ERROR, POSTHOG_TAG, "Method $method, error: $error")
191
278
  }
192
279
 
280
+ private fun isPosthogHost(value: String): Boolean {
281
+ val hostName =
282
+ try {
283
+ URI(value).host?.lowercase() ?: value.lowercase()
284
+ } catch (_: Throwable) {
285
+ value.lowercase()
286
+ }
287
+
288
+ return hostName == "posthog.com" || hostName.endsWith(".posthog.com")
289
+ }
290
+
193
291
  companion object {
194
292
  const val NAME = "PosthogReactNativeSessionReplay"
195
293
  const val POSTHOG_TAG = "PostHog"
@@ -19,6 +19,9 @@ RCT_EXTERN_METHOD(isEnabled:(RCTPromiseResolveBlock)resolve
19
19
  RCT_EXTERN_METHOD(endSession:(RCTPromiseResolveBlock)resolve
20
20
  withRejecter:(RCTPromiseRejectBlock)reject)
21
21
 
22
+ RCT_EXTERN_METHOD(flush:(RCTPromiseResolveBlock)resolve
23
+ withRejecter:(RCTPromiseRejectBlock)reject)
24
+
22
25
  RCT_EXTERN_METHOD(identify:(NSString)distinctId
23
26
  withAnonymousId:(NSString)anonymousId
24
27
  withResolver:(RCTPromiseResolveBlock)resolve
@@ -1,8 +1,134 @@
1
+ import Foundation
1
2
  import PostHog
2
3
 
3
- // Meant for internally logging PostHog related things
4
- private func hedgeLog(_ message: String) {
5
- print("[PostHog] \(message)")
4
+ private func normalizedHost(from value: String) -> String {
5
+ if let url = URL(string: value), let host = url.host {
6
+ return host.lowercased()
7
+ }
8
+ return value.lowercased()
9
+ }
10
+
11
+ private final class PlushReplayAuthURLProtocol: URLProtocol, URLSessionDataDelegate {
12
+ private static let handledKey = "PlushReplayAuthHandled"
13
+ private static var apiKey: String = ""
14
+ private static var allowedHost: String = ""
15
+ private static var allowedPathSuffixes: [String] = ["/v1/events/batch", "/batch"]
16
+
17
+ private var outboundDataTask: URLSessionDataTask?
18
+ private var outboundSession: URLSession?
19
+
20
+ static func configure(apiKey: String, host: String, snapshotPath: String) {
21
+ Self.apiKey = apiKey
22
+ Self.allowedHost = normalizedHost(from: host)
23
+
24
+ let trimmedPath = snapshotPath.trimmingCharacters(in: .whitespacesAndNewlines)
25
+ let normalizedPath: String
26
+ if trimmedPath.isEmpty {
27
+ normalizedPath = "/v1/events/batch"
28
+ } else {
29
+ normalizedPath = trimmedPath.hasPrefix("/") ? trimmedPath : "/\(trimmedPath)"
30
+ }
31
+
32
+ var suffixes = ["/v1/events/batch", "/batch"]
33
+ if !suffixes.contains(normalizedPath) {
34
+ suffixes.insert(normalizedPath, at: 0)
35
+ }
36
+ Self.allowedPathSuffixes = suffixes
37
+ }
38
+
39
+ override class func canInit(with request: URLRequest) -> Bool {
40
+ guard URLProtocol.property(forKey: handledKey, in: request) == nil else {
41
+ return false
42
+ }
43
+
44
+ guard let url = request.url else {
45
+ return false
46
+ }
47
+
48
+ let requestHost = url.host?.lowercased() ?? ""
49
+ if !Self.allowedHost.isEmpty && requestHost != Self.allowedHost {
50
+ return false
51
+ }
52
+
53
+ return Self.allowedPathSuffixes.contains { suffix in
54
+ url.path.hasSuffix(suffix)
55
+ }
56
+ }
57
+
58
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest {
59
+ request
60
+ }
61
+
62
+ override func startLoading() {
63
+ guard let mutableRequest = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else {
64
+ client?.urlProtocol(self, didFailWithError: NSError(domain: "PlushReplayAuthURLProtocol", code: -1))
65
+ return
66
+ }
67
+
68
+ URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest)
69
+
70
+ if !Self.apiKey.isEmpty {
71
+ mutableRequest.setValue(Self.apiKey, forHTTPHeaderField: "x-api-key")
72
+ mutableRequest.setValue(Self.apiKey, forHTTPHeaderField: "x-plush-api-key")
73
+ mutableRequest.setValue(Self.apiKey, forHTTPHeaderField: "x-plushanalytics-key")
74
+ mutableRequest.setValue("Bearer \(Self.apiKey)", forHTTPHeaderField: "authorization")
75
+ }
76
+
77
+ let outboundRequest = mutableRequest as URLRequest
78
+ let configuration = URLSessionConfiguration.default
79
+ configuration.protocolClasses = []
80
+
81
+ outboundSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
82
+ outboundDataTask = outboundSession?.dataTask(with: outboundRequest)
83
+ outboundDataTask?.resume()
84
+ }
85
+
86
+ override func stopLoading() {
87
+ outboundDataTask?.cancel()
88
+ outboundDataTask = nil
89
+ outboundSession?.invalidateAndCancel()
90
+ outboundSession = nil
91
+ }
92
+
93
+ func urlSession(
94
+ _ session: URLSession,
95
+ dataTask: URLSessionDataTask,
96
+ didReceive response: URLResponse,
97
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
98
+ ) {
99
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
100
+ completionHandler(.allow)
101
+ }
102
+
103
+ func urlSession(
104
+ _ session: URLSession,
105
+ dataTask: URLSessionDataTask,
106
+ didReceive data: Data
107
+ ) {
108
+ client?.urlProtocol(self, didLoad: data)
109
+ }
110
+
111
+ func urlSession(
112
+ _ session: URLSession,
113
+ task: URLSessionTask,
114
+ didCompleteWithError error: Error?
115
+ ) {
116
+ if let error {
117
+ client?.urlProtocol(self, didFailWithError: error)
118
+ return
119
+ }
120
+
121
+ client?.urlProtocolDidFinishLoading(self)
122
+ }
123
+ }
124
+
125
+ private func isPosthogHost(_ value: String) -> Bool {
126
+ let normalizedHost = normalizedHost(from: value)
127
+ return normalizedHost == "posthog.com" || normalizedHost.hasSuffix(".posthog.com")
128
+ }
129
+
130
+ private func logNativeError(_ message: String) {
131
+ NSLog("[Plush][RNReplay] %@", message)
6
132
  }
7
133
 
8
134
  @objc(PosthogReactNativeSessionReplay)
@@ -11,13 +137,15 @@ class PosthogReactNativeSessionReplay: NSObject {
11
137
 
12
138
  @objc(start:withSdkOptions:withSdkReplayConfig:withDecideReplayConfig:withResolver:withRejecter:)
13
139
  func start(
14
- sessionId: String, sdkOptions: [String: Any], sdkReplayConfig: [String: Any],
15
- decideReplayConfig: [String: Any], resolve: RCTPromiseResolveBlock,
16
- reject _: RCTPromiseRejectBlock
140
+ sessionId: String,
141
+ sdkOptions: [String: Any],
142
+ sdkReplayConfig: [String: Any],
143
+ decideReplayConfig: [String: Any],
144
+ resolve: RCTPromiseResolveBlock,
145
+ reject: RCTPromiseRejectBlock
17
146
  ) {
18
- // we've seen cases where distinctId and anonymousId are not strings, so we need to check and convert them
19
147
  guard let sessionIdStr = sessionId as? String else {
20
- hedgeLog("Invalid sessionId provided: \(sessionId). Expected a string.")
148
+ logNativeError("Invalid sessionId provided. Expected a string.")
21
149
  resolve(nil)
22
150
  return
23
151
  }
@@ -26,15 +154,44 @@ class PosthogReactNativeSessionReplay: NSObject {
26
154
  let host = sdkOptions["host"] as? String ?? PostHogConfig.defaultHost
27
155
  let debug = sdkOptions["debug"] as? Bool ?? false
28
156
 
157
+ if isPosthogHost(host) {
158
+ let message = "Blocked replay host '\(host)' because posthog.com hosts are not allowed."
159
+ reject("PLUSH_BLOCKED_REPLAY_HOST", message, nil)
160
+ return
161
+ }
162
+
29
163
  PostHogSessionManager.shared.setSessionId(sessionIdStr)
30
164
 
31
165
  let config = PostHogConfig(apiKey: apiKey, host: host)
32
166
  config.sessionReplay = true
33
167
  config.captureApplicationLifecycleEvents = false
34
168
  config.captureScreenViews = false
169
+ config.preloadFeatureFlags = false
170
+ config.remoteConfig = false
35
171
  config.debug = debug
36
172
  config.sessionReplayConfig.screenshotMode = true
37
173
 
174
+ let endpoint = decideReplayConfig["endpoint"] as? String ?? ""
175
+ if !endpoint.isEmpty {
176
+ config.snapshotEndpoint = endpoint
177
+ }
178
+
179
+ PlushReplayAuthURLProtocol.configure(
180
+ apiKey: apiKey,
181
+ host: host,
182
+ snapshotPath: config.snapshotEndpoint
183
+ )
184
+
185
+ let sessionConfiguration = URLSessionConfiguration.default
186
+ sessionConfiguration.httpAdditionalHeaders = [
187
+ "x-api-key": apiKey,
188
+ "x-plush-api-key": apiKey,
189
+ "x-plushanalytics-key": apiKey,
190
+ "authorization": "Bearer \(apiKey)",
191
+ ]
192
+ sessionConfiguration.protocolClasses = [PlushReplayAuthURLProtocol.self]
193
+ config.urlSessionConfiguration = sessionConfiguration
194
+
38
195
  if #available(iOS 15.0, *) {
39
196
  config.surveys = false
40
197
  }
@@ -48,7 +205,7 @@ class PosthogReactNativeSessionReplay: NSObject {
48
205
  let maskAllSandboxedViews = sdkReplayConfig["maskAllSandboxedViews"] as? Bool ?? true
49
206
  config.sessionReplayConfig.maskAllSandboxedViews = maskAllSandboxedViews
50
207
 
51
- // read throttleDelayMs and use iOSdebouncerDelayMs as a fallback for back compatibility
208
+ // Read throttleDelayMs and use iOSdebouncerDelayMs as a fallback for back compatibility.
52
209
  let throttleDelayMs =
53
210
  (sdkReplayConfig["throttleDelayMs"] as? Int)
54
211
  ?? (sdkReplayConfig["iOSdebouncerDelayMs"] as? Int)
@@ -60,11 +217,6 @@ class PosthogReactNativeSessionReplay: NSObject {
60
217
  let captureNetworkTelemetry = sdkReplayConfig["captureNetworkTelemetry"] as? Bool ?? true
61
218
  config.sessionReplayConfig.captureNetworkTelemetry = captureNetworkTelemetry
62
219
 
63
- let endpoint = decideReplayConfig["endpoint"] as? String ?? ""
64
- if !endpoint.isEmpty {
65
- config.snapshotEndpoint = endpoint
66
- }
67
-
68
220
  let distinctId = sdkOptions["distinctId"] as? String ?? ""
69
221
  let anonymousId = sdkOptions["anonymousId"] as? String ?? ""
70
222
 
@@ -79,30 +231,30 @@ class PosthogReactNativeSessionReplay: NSObject {
79
231
  }
80
232
 
81
233
  PostHogSDK.shared.setup(config)
82
-
83
234
  self.config = config
84
235
 
85
236
  guard let storageManager = self.config?.storageManager else {
86
- hedgeLog("Storage manager is not available in the config.")
237
+ logNativeError("Storage manager is not available in the config.")
87
238
  resolve(nil)
88
239
  return
89
240
  }
90
241
 
91
242
  setIdentify(storageManager, distinctId: distinctId, anonymousId: anonymousId)
92
-
93
243
  resolve(nil)
94
244
  }
95
245
 
96
246
  @objc(startSession:withResolver:withRejecter:)
97
247
  func startSession(
98
- sessionId: String, resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock
248
+ sessionId: String,
249
+ resolve: RCTPromiseResolveBlock,
250
+ reject _: RCTPromiseRejectBlock
99
251
  ) {
100
- // we've seen cases where distinctId and anonymousId are not strings, so we need to check and convert them
101
252
  guard let sessionIdStr = sessionId as? String else {
102
- hedgeLog("Invalid sessionId provided: \(sessionId). Expected a string.")
253
+ logNativeError("Invalid sessionId provided. Expected a string.")
103
254
  resolve(nil)
104
255
  return
105
256
  }
257
+
106
258
  PostHogSessionManager.shared.setSessionId(sessionIdStr)
107
259
  PostHogSDK.shared.startSession()
108
260
  resolve(nil)
@@ -110,8 +262,7 @@ class PosthogReactNativeSessionReplay: NSObject {
110
262
 
111
263
  @objc(isEnabled:withRejecter:)
112
264
  func isEnabled(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) {
113
- let isEnabled = PostHogSDK.shared.isSessionReplayActive()
114
- resolve(isEnabled)
265
+ resolve(PostHogSDK.shared.isSessionReplayActive())
115
266
  }
116
267
 
117
268
  @objc(endSession:withRejecter:)
@@ -120,33 +271,33 @@ class PosthogReactNativeSessionReplay: NSObject {
120
271
  resolve(nil)
121
272
  }
122
273
 
274
+ @objc(flush:withRejecter:)
275
+ func flush(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) {
276
+ PostHogSDK.shared.flush()
277
+ resolve(nil)
278
+ }
279
+
123
280
  @objc(identify:withAnonymousId:withResolver:withRejecter:)
124
281
  func identify(
125
- distinctId: String, anonymousId: String, resolve: RCTPromiseResolveBlock,
282
+ distinctId: String,
283
+ anonymousId: String,
284
+ resolve: RCTPromiseResolveBlock,
126
285
  reject _: RCTPromiseRejectBlock
127
286
  ) {
128
- // we've seen cases where distinctId and anonymousId are not strings, so we need to check and convert them
129
- guard let distinctIdStr = distinctId as? String,
130
- let anonymousIdStr = anonymousId as? String
131
- else {
132
- hedgeLog(
133
- "Invalid distinctId: \(distinctId) or anonymousId: \(anonymousId) provided. Expected strings."
134
- )
135
- resolve(nil)
136
- return
137
- }
138
287
  guard let storageManager = config?.storageManager else {
139
- hedgeLog("Storage manager is not available in the config.")
288
+ logNativeError("Storage manager is not available in the config.")
140
289
  resolve(nil)
141
290
  return
142
291
  }
143
- setIdentify(storageManager, distinctId: distinctIdStr, anonymousId: anonymousIdStr)
144
292
 
293
+ setIdentify(storageManager, distinctId: distinctId, anonymousId: anonymousId)
145
294
  resolve(nil)
146
295
  }
147
296
 
148
297
  private func setIdentify(
149
- _ storageManager: PostHogStorageManager, distinctId: String, anonymousId: String
298
+ _ storageManager: PostHogStorageManager,
299
+ distinctId: String,
300
+ anonymousId: String
150
301
  ) {
151
302
  if !anonymousId.isEmpty {
152
303
  storageManager.setAnonymousId(anonymousId)