@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.
- package/README.md +59 -2
- package/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayModule.kt +125 -27
- package/ios/PosthogReactNativeSessionReplay.mm +3 -0
- package/ios/PosthogReactNativeSessionReplay.swift +187 -36
- package/lib/commonjs/adapters/mobileReplayAdapter.js +55 -18
- package/lib/commonjs/adapters/mobileReplayAdapter.js.map +1 -1
- package/lib/commonjs/client.js +2 -1
- package/lib/commonjs/client.js.map +1 -1
- package/lib/commonjs/nativeBridge.js +5 -0
- package/lib/commonjs/nativeBridge.js.map +1 -1
- package/lib/module/adapters/mobileReplayAdapter.js +55 -18
- package/lib/module/adapters/mobileReplayAdapter.js.map +1 -1
- package/lib/module/client.js +2 -1
- package/lib/module/client.js.map +1 -1
- package/lib/module/nativeBridge.js +4 -0
- package/lib/module/nativeBridge.js.map +1 -1
- package/lib/typescript/commonjs/src/adapters/mobileReplayAdapter.d.ts +2 -1
- package/lib/typescript/commonjs/src/adapters/mobileReplayAdapter.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +1 -1
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/nativeBridge.d.ts +2 -0
- package/lib/typescript/commonjs/src/nativeBridge.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/types.d.ts +16 -5
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -1
- package/lib/typescript/module/src/adapters/mobileReplayAdapter.d.ts +2 -1
- package/lib/typescript/module/src/adapters/mobileReplayAdapter.d.ts.map +1 -1
- package/lib/typescript/module/src/client.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +1 -1
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/nativeBridge.d.ts +2 -0
- package/lib/typescript/module/src/nativeBridge.d.ts.map +1 -1
- package/lib/typescript/module/src/types.d.ts +16 -5
- package/lib/typescript/module/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/mobileReplayAdapter.ts +112 -19
- package/src/client.ts +2 -1
- package/src/index.tsx +2 -0
- package/src/nativeBridge.ts +8 -1
- 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 =
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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 (!
|
|
99
|
+
if (!sdkVersion.isNullOrEmpty()) {
|
|
97
100
|
sdkName = "posthog-react-native"
|
|
98
|
-
sdkVersion =
|
|
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
|
-
//
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
298
|
+
_ storageManager: PostHogStorageManager,
|
|
299
|
+
distinctId: String,
|
|
300
|
+
anonymousId: String
|
|
150
301
|
) {
|
|
151
302
|
if !anonymousId.isEmpty {
|
|
152
303
|
storageManager.setAnonymousId(anonymousId)
|