@posthog/react-native-plugin 2.0.0 → 2.0.1

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/LICENSE CHANGED
@@ -324,3 +324,30 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
324
324
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
325
325
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
326
326
  SOFTWARE.
327
+
328
+ ---
329
+
330
+ Some files in this codebase contain code from MCPCat/mcpcat-typescript-sdk.
331
+ In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.
332
+
333
+ MIT License
334
+
335
+ Copyright (c) 2025 MCPcat
336
+
337
+ Permission is hereby granted, free of charge, to any person obtaining a copy
338
+ of this software and associated documentation files (the "Software"), to deal
339
+ in the Software without restriction, including without limitation the rights
340
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
341
+ copies of the Software, and to permit persons to whom the Software is
342
+ furnished to do so, subject to the following conditions:
343
+
344
+ The above copyright notice and this permission notice shall be included in all
345
+ copies or substantial portions of the Software.
346
+
347
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
348
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
349
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
350
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
351
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
352
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
353
+ SOFTWARE.
@@ -9,6 +9,7 @@ import com.facebook.react.bridge.ReadableMap
9
9
  import com.facebook.react.bridge.UiThreadUtil
10
10
  import com.posthog.PostHog
11
11
  import com.posthog.PostHogConfig
12
+ import com.posthog.PostHogEvent
12
13
  import com.posthog.android.PostHogAndroid
13
14
  import com.posthog.android.PostHogAndroidConfig
14
15
  import com.posthog.internal.PostHogPreferences
@@ -98,31 +99,36 @@ class PosthogReactNativePluginModule(
98
99
  flushAt = theFlushAt
99
100
  errorTrackingConfig.autoCapture = nativeErrorTrackingAutocapture
100
101
 
101
- if (sessionReplayEnabled) {
102
- val maskAllTextInputs = getBoolean(sdkReplayConfig, "maskAllTextInputs", DEFAULT_MASK_ALL_TEXT_INPUTS)
103
- val maskAllImages = getBoolean(sdkReplayConfig, "maskAllImages", DEFAULT_MASK_ALL_IMAGES)
104
- val captureLog = getBoolean(sdkReplayConfig, "captureLog", DEFAULT_CAPTURE_LOG)
105
-
106
- // read throttleDelayMs and use androidDebouncerDelayMs as a fallback for back compatibility
107
- val throttleDelayMs =
108
- when {
109
- hasKey(sdkReplayConfig, "throttleDelayMs") -> getInt(sdkReplayConfig, "throttleDelayMs", DEFAULT_THROTTLE_DELAY_MS)
110
- hasKey(sdkReplayConfig, "androidDebouncerDelayMs") -> getInt(sdkReplayConfig, "androidDebouncerDelayMs", DEFAULT_THROTTLE_DELAY_MS)
111
- else -> DEFAULT_THROTTLE_DELAY_MS
112
- }
113
-
114
- sessionReplay = true
115
- sessionReplayConfig.screenshot = true
116
- sessionReplayConfig.captureLogcat = captureLog
117
- sessionReplayConfig.throttleDelayMs = throttleDelayMs.toLong()
118
- sessionReplayConfig.maskAllImages = maskAllImages
119
- sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
120
- sessionReplayConfig.sampleRate = getDoubleOrNull(sdkReplayConfig, "sampleRate")
121
-
122
- val endpoint = getString(decideReplayConfig, "endpoint", "")
123
- if (endpoint.isNotEmpty()) {
124
- snapshotEndpoint = endpoint
102
+ // React Native rethrows fatal JS errors natively as JavascriptException.
103
+ // The JS layer already captured them, so drop the native duplicate.
104
+ addBeforeSend { event -> if (isReactNativeFatalJsError(event)) null else event }
105
+
106
+ // Always apply the session replay configuration so that recording started later
107
+ // (e.g. startRecording or a linked feature flag) uses the right mode and masking;
108
+ // sessionReplayEnabled only controls whether recording starts at setup.
109
+ val maskAllTextInputs = getBoolean(sdkReplayConfig, "maskAllTextInputs", DEFAULT_MASK_ALL_TEXT_INPUTS)
110
+ val maskAllImages = getBoolean(sdkReplayConfig, "maskAllImages", DEFAULT_MASK_ALL_IMAGES)
111
+ val captureLog = getBoolean(sdkReplayConfig, "captureLog", DEFAULT_CAPTURE_LOG)
112
+
113
+ // read throttleDelayMs and use androidDebouncerDelayMs as a fallback for back compatibility
114
+ val throttleDelayMs =
115
+ when {
116
+ hasKey(sdkReplayConfig, "throttleDelayMs") -> getInt(sdkReplayConfig, "throttleDelayMs", DEFAULT_THROTTLE_DELAY_MS)
117
+ hasKey(sdkReplayConfig, "androidDebouncerDelayMs") -> getInt(sdkReplayConfig, "androidDebouncerDelayMs", DEFAULT_THROTTLE_DELAY_MS)
118
+ else -> DEFAULT_THROTTLE_DELAY_MS
125
119
  }
120
+
121
+ sessionReplay = sessionReplayEnabled
122
+ sessionReplayConfig.screenshot = true
123
+ sessionReplayConfig.captureLogcat = captureLog
124
+ sessionReplayConfig.throttleDelayMs = throttleDelayMs.toLong()
125
+ sessionReplayConfig.maskAllImages = maskAllImages
126
+ sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
127
+ sessionReplayConfig.sampleRate = getDoubleOrNull(sdkReplayConfig, "sampleRate")
128
+
129
+ val endpoint = getString(decideReplayConfig, "endpoint", "")
130
+ if (endpoint.isNotEmpty()) {
131
+ snapshotEndpoint = endpoint
126
132
  }
127
133
 
128
134
  if (theSdkVersion.isNotEmpty()) {
@@ -280,6 +286,16 @@ class PosthogReactNativePluginModule(
280
286
  key: String,
281
287
  ): Double? = runCatching { if (hasKey(map, key)) map?.getDouble(key) else null }.getOrNull()
282
288
 
289
+ private fun isReactNativeFatalJsError(event: PostHogEvent): Boolean {
290
+ if (event.event != "\$exception") return false
291
+ val exceptionList = event.properties?.get("\$exception_list") as? List<*> ?: return false
292
+ return exceptionList.any { item ->
293
+ val exception = item as? Map<*, *> ?: return@any false
294
+ exception["type"] == "JavascriptException" &&
295
+ (exception["module"] as? String)?.startsWith("com.facebook.react") == true
296
+ }
297
+ }
298
+
283
299
  private fun logError(
284
300
  method: String,
285
301
  error: Throwable,
@@ -5,6 +5,36 @@ private func hedgeLog(_ message: String) {
5
5
  print("[PostHog] \(message)")
6
6
  }
7
7
 
8
+ // Deduplication works on Android (both architectures) and iOS (old architecture only).
9
+ // On the iOS new architecture, fatal JS exception events surface as a generic SIGABRT
10
+ // crash event with no JS-error text in any field, so they currently cannot be filtered.
11
+ private let fatalJsErrorMarkers = ["Unhandled JS Exception", "ExceptionsManager.reportException", "facebook::jsi::JSError"]
12
+
13
+ private func containsFatalJsErrorMarker(_ text: String?) -> Bool {
14
+ guard let text else { return false }
15
+ return fatalJsErrorMarkers.contains { text.contains($0) }
16
+ }
17
+
18
+ private func isReactNativeFatalJsError(_ event: PostHogEvent) -> Bool {
19
+ guard event.event == "$exception",
20
+ let exceptionList = event.properties["$exception_list"] as? [[String: Any]]
21
+ else { return false }
22
+ return exceptionList.contains { exception in
23
+ if containsFatalJsErrorMarker(exception["type"] as? String) {
24
+ return true
25
+ }
26
+ if containsFatalJsErrorMarker(exception["value"] as? String) {
27
+ return true
28
+ }
29
+ // New-architecture RN rethrows fatal JS errors as a C++ jsi::JSError (SIGABRT);
30
+ // the JS-error text only survives in the signal's crash-info message.
31
+ let mechanism = exception["mechanism"] as? [String: Any]
32
+ let meta = mechanism?["meta"] as? [String: Any]
33
+ let signal = meta?["signal"] as? [String: Any]
34
+ return containsFatalJsErrorMarker(signal?["crash_info_message"] as? String)
35
+ }
36
+ }
37
+
8
38
  @objc(PosthogReactNativePlugin)
9
39
  class PosthogReactNativePlugin: NSObject {
10
40
  private var config: PostHogConfig?
@@ -78,47 +108,54 @@ class PosthogReactNativePlugin: NSObject {
78
108
  config.debug = debug
79
109
  config.errorTrackingConfig.autoCapture = nativeErrorTrackingAutocapture
80
110
 
111
+ // React Native rethrows fatal JS errors natively (RCTFatalException / ExceptionsManager).
112
+ // The JS layer already captured them, so drop the native duplicate.
113
+ config.setBeforeSend { event in
114
+ isReactNativeFatalJsError(event) ? nil : event
115
+ }
116
+
81
117
  if #available(iOS 15.0, *) {
82
118
  config.surveys = false
83
119
  }
84
120
 
85
- if sessionReplayEnabled {
86
- config.sessionReplay = true
87
- config.sessionReplayConfig.screenshotMode = true
121
+ // Always apply the session replay configuration so that recording started later
122
+ // (e.g. startRecording or a linked feature flag) uses the right mode and masking;
123
+ // sessionReplayEnabled only controls whether recording starts at setup.
124
+ config.sessionReplay = sessionReplayEnabled
125
+ config.sessionReplayConfig.screenshotMode = true
88
126
 
89
- let maskAllTextInputs = sdkReplayConfig["maskAllTextInputs"] as? Bool ?? true
90
- config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
127
+ let maskAllTextInputs = sdkReplayConfig["maskAllTextInputs"] as? Bool ?? true
128
+ config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs
91
129
 
92
- let maskAllImages = sdkReplayConfig["maskAllImages"] as? Bool ?? true
93
- config.sessionReplayConfig.maskAllImages = maskAllImages
130
+ let maskAllImages = sdkReplayConfig["maskAllImages"] as? Bool ?? true
131
+ config.sessionReplayConfig.maskAllImages = maskAllImages
94
132
 
95
- let maskAllSandboxedViews = sdkReplayConfig["maskAllSandboxedViews"] as? Bool ?? true
96
- config.sessionReplayConfig.maskAllSandboxedViews = maskAllSandboxedViews
133
+ let maskAllSandboxedViews = sdkReplayConfig["maskAllSandboxedViews"] as? Bool ?? true
134
+ config.sessionReplayConfig.maskAllSandboxedViews = maskAllSandboxedViews
97
135
 
98
- // read throttleDelayMs and use iOSdebouncerDelayMs as a fallback for back compatibility
99
- let throttleDelayMs =
100
- (sdkReplayConfig["throttleDelayMs"] as? Int)
101
- ?? (sdkReplayConfig["iOSdebouncerDelayMs"] as? Int)
102
- ?? 1000
136
+ // read throttleDelayMs and use iOSdebouncerDelayMs as a fallback for back compatibility
137
+ let throttleDelayMs =
138
+ (sdkReplayConfig["throttleDelayMs"] as? Int)
139
+ ?? (sdkReplayConfig["iOSdebouncerDelayMs"] as? Int)
140
+ ?? 1000
103
141
 
104
- let timeInterval: TimeInterval = Double(throttleDelayMs) / 1000.0
105
- config.sessionReplayConfig.throttleDelay = timeInterval
142
+ let timeInterval: TimeInterval = Double(throttleDelayMs) / 1000.0
143
+ config.sessionReplayConfig.throttleDelay = timeInterval
106
144
 
107
- let captureNetworkTelemetry = sdkReplayConfig["captureNetworkTelemetry"] as? Bool ?? true
108
- config.sessionReplayConfig.captureNetworkTelemetry = captureNetworkTelemetry
145
+ let captureNetworkTelemetry = sdkReplayConfig["captureNetworkTelemetry"] as? Bool ?? true
146
+ config.sessionReplayConfig.captureNetworkTelemetry = captureNetworkTelemetry
109
147
 
110
- let captureLog = sdkReplayConfig["captureLog"] as? Bool ?? true
111
- config.sessionReplayConfig.captureLogs = captureLog
148
+ let captureLog = sdkReplayConfig["captureLog"] as? Bool ?? true
149
+ config.sessionReplayConfig.captureLogs = captureLog
112
150
 
113
- config.sessionReplayConfig.sampleRate = sdkReplayConfig["sampleRate"] as? NSNumber
151
+ config.sessionReplayConfig.sampleRate = sdkReplayConfig["sampleRate"] as? NSNumber
114
152
 
115
- let screenshotModeBackgroundCapture = sdkReplayConfig["screenshotModeBackgroundCapture"] as? Bool ?? false
116
- config.sessionReplayConfig.screenshotModeBackgroundCapture = screenshotModeBackgroundCapture
153
+ let screenshotModeBackgroundCapture = sdkReplayConfig["screenshotModeBackgroundCapture"] as? Bool ?? false
154
+ config.sessionReplayConfig.screenshotModeBackgroundCapture = screenshotModeBackgroundCapture
117
155
 
118
- let endpoint = decideReplayConfig["endpoint"] as? String ?? ""
119
- if !endpoint.isEmpty {
120
- config.snapshotEndpoint = endpoint
121
- }
156
+ let endpoint = decideReplayConfig["endpoint"] as? String ?? ""
157
+ if !endpoint.isEmpty {
158
+ config.snapshotEndpoint = endpoint
122
159
  }
123
160
 
124
161
  let distinctId = sdkOptions["distinctId"] as? String ?? ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/react-native-plugin",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "PostHog React Native plugin for iOS and Android integrations",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/commonjs/index.js",