diff --git a/.gitignore b/.gitignore index 390910d..f00d44f 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,5 @@ lib/ # React Native Codegen ios/generated android/generated + +.cursor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 183d471..c9829e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## Next +## 1.1.1 - 2025-06-24 + +- fix: check if distinctId and anonId are a string before using it +- chore: pin the iOS SDK to 3.21.x +- chore: pin the Android SDK to 3.19.1 + ## 1.1.0 - 2025-06-06 - chore: remove maskPhotoLibraryImages from the SDK config diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..70b986f --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: formatKotlin formatSwift + +# brew install ktlint +formatKotlin: + ktlint -F "**/*.kt" + +# brew install swift-format +formatSwift: + swiftformat ios --swiftversion 5.3 diff --git a/android/build.gradle b/android/build.gradle index d9d46fd..2c748b7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -95,6 +95,6 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.posthog:posthog-android:3.11.3" + implementation "com.posthog:posthog-android:3.19.1" } diff --git a/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayModule.kt b/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayModule.kt index 25ab024..45f3d8a 100644 --- a/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayModule.kt +++ b/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayModule.kt @@ -1,13 +1,12 @@ package com.posthogreactnativesessionreplay import android.util.Log +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.UiThreadUtil - import com.posthog.PostHog import com.posthog.PostHogConfig import com.posthog.android.PostHogAndroid @@ -18,12 +17,10 @@ import com.posthog.internal.PostHogPreferences.Companion.DISTINCT_ID import com.posthog.internal.PostHogSessionManager import java.util.UUID -class PosthogReactNativeSessionReplayModule(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { - - override fun getName(): String { - return NAME - } +class PosthogReactNativeSessionReplayModule( + reactContext: ReactApplicationContext, +) : ReactContextBaseJavaModule(reactContext) { + override fun getName(): String = NAME @ReactMethod fun start( @@ -31,75 +28,79 @@ class PosthogReactNativeSessionReplayModule(reactContext: ReactApplicationContex sdkOptions: ReadableMap, sdkReplayConfig: ReadableMap, decideReplayConfig: ReadableMap, - promise: Promise + promise: Promise, ) { - val initRunnable = Runnable { - try { - val uuid = UUID.fromString(sessionId) - PostHogSessionManager.setSessionId(uuid) - - val context = this.reactApplicationContext - val apiKey = sdkOptions.getString("apiKey") ?: "" - val host = sdkOptions.getString("host") ?: PostHogConfig.DEFAULT_HOST - val debugValue = sdkOptions.getBoolean("debug") - - val maskAllTextInputs = sdkReplayConfig.getBoolean("maskAllTextInputs") - val maskAllImages = sdkReplayConfig.getBoolean("maskAllImages") - val captureLog = sdkReplayConfig.getBoolean("captureLog") - val debouncerDelayMs = sdkReplayConfig.getInt("androidDebouncerDelayMs") - - val endpoint = decideReplayConfig.getString("endpoint") - - val distinctId = try { - sdkOptions.getString("distinctId") ?: "" - } catch (e: Throwable) { - logError("parse distinctId", e) - "" - } - val anonymousId = try { - sdkOptions.getString("anonymousId") ?: "" - } catch (e: Throwable) { - logError("parse anonymousId", e) - "" - } - val theSdkVersion = sdkOptions.getString("sdkVersion") - - var theFlushAt = 20 - if (sdkOptions.hasKey("flushAt")) { - theFlushAt = sdkOptions.getInt("flushAt") - } - - val config = PostHogAndroidConfig(apiKey, host).apply { - debug = debugValue - captureDeepLinks = false - captureApplicationLifecycleEvents = false - captureScreenViews = false - flushAt = theFlushAt - sessionReplay = true - sessionReplayConfig.screenshot = true - sessionReplayConfig.captureLogcat = captureLog - sessionReplayConfig.debouncerDelayMs = debouncerDelayMs.toLong() - sessionReplayConfig.maskAllImages = maskAllImages - sessionReplayConfig.maskAllTextInputs = maskAllTextInputs - - if (!endpoint.isNullOrEmpty()) { - snapshotEndpoint = endpoint + val initRunnable = + Runnable { + try { + val uuid = UUID.fromString(sessionId) + PostHogSessionManager.setSessionId(uuid) + + val context = this.reactApplicationContext + val apiKey = sdkOptions.getString("apiKey") ?: "" + val host = sdkOptions.getString("host") ?: PostHogConfig.DEFAULT_HOST + val debugValue = sdkOptions.getBoolean("debug") + + val maskAllTextInputs = sdkReplayConfig.getBoolean("maskAllTextInputs") + val maskAllImages = sdkReplayConfig.getBoolean("maskAllImages") + val captureLog = sdkReplayConfig.getBoolean("captureLog") + val debouncerDelayMs = sdkReplayConfig.getInt("androidDebouncerDelayMs") + + val endpoint = decideReplayConfig.getString("endpoint") + + val distinctId = + try { + sdkOptions.getString("distinctId") ?: "" + } catch (e: Throwable) { + logError("parse distinctId", e) + "" + } + val anonymousId = + try { + sdkOptions.getString("anonymousId") ?: "" + } catch (e: Throwable) { + logError("parse anonymousId", e) + "" + } + val theSdkVersion = sdkOptions.getString("sdkVersion") + + var theFlushAt = 20 + if (sdkOptions.hasKey("flushAt")) { + theFlushAt = sdkOptions.getInt("flushAt") } - if (!theSdkVersion.isNullOrEmpty()) { - sdkName = "posthog-react-native" - sdkVersion = theSdkVersion - } + val config = + PostHogAndroidConfig(apiKey, host).apply { + debug = debugValue + captureDeepLinks = false + captureApplicationLifecycleEvents = false + captureScreenViews = false + flushAt = theFlushAt + sessionReplay = true + sessionReplayConfig.screenshot = true + sessionReplayConfig.captureLogcat = captureLog + sessionReplayConfig.throttleDelayMs = debouncerDelayMs.toLong() + sessionReplayConfig.maskAllImages = maskAllImages + sessionReplayConfig.maskAllTextInputs = maskAllTextInputs + + if (!endpoint.isNullOrEmpty()) { + snapshotEndpoint = endpoint + } + + if (!theSdkVersion.isNullOrEmpty()) { + sdkName = "posthog-react-native" + sdkVersion = theSdkVersion + } + } + PostHogAndroid.setup(context, config) + + setIdentify(config.cachePreferences, distinctId, anonymousId) + } catch (e: Throwable) { + logError("start", e) + } finally { + promise.resolve(null) } - PostHogAndroid.setup(context, config) - - setIdentify(config.cachePreferences, distinctId, anonymousId) - } catch (e: Throwable) { - logError("start", e) - } finally { - promise.resolve(null) } - } // forces the SDK to be initialized on the main thread if (UiThreadUtil.isOnUiThread()) { @@ -110,7 +111,10 @@ class PosthogReactNativeSessionReplayModule(reactContext: ReactApplicationContex } @ReactMethod - fun startSession(sessionId: String, promise: Promise) { + fun startSession( + sessionId: String, + promise: Promise, + ) { try { val uuid = UUID.fromString(sessionId) PostHogSessionManager.setSessionId(uuid) @@ -144,7 +148,11 @@ class PosthogReactNativeSessionReplayModule(reactContext: ReactApplicationContex } @ReactMethod - fun identify(distinctId: String, anonymousId: String, promise: Promise) { + fun identify( + distinctId: String, + anonymousId: String, + promise: Promise, + ) { try { setIdentify(PostHog.getConfig()?.cachePreferences, distinctId, anonymousId) } catch (e: Throwable) { @@ -157,7 +165,7 @@ class PosthogReactNativeSessionReplayModule(reactContext: ReactApplicationContex private fun setIdentify( cachePreferences: PostHogPreferences?, distinctId: String, - anonymousId: String + anonymousId: String, ) { cachePreferences?.let { preferences -> if (anonymousId.isNotEmpty()) { @@ -169,7 +177,10 @@ class PosthogReactNativeSessionReplayModule(reactContext: ReactApplicationContex } } - private fun logError(method: String, error: Throwable) { + private fun logError( + method: String, + error: Throwable, + ) { Log.println(Log.ERROR, POSTHOG_TAG, "Method $method, error: $error") } diff --git a/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayPackage.kt b/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayPackage.kt index 627546b..6dfcb5f 100644 --- a/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayPackage.kt +++ b/android/src/main/java/com/posthogreactnativesessionreplay/PosthogReactNativeSessionReplayPackage.kt @@ -5,13 +5,9 @@ import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager - class PosthogReactNativeSessionReplayPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(PosthogReactNativeSessionReplayModule(reactContext)) - } + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(PosthogReactNativeSessionReplayModule(reactContext)) - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return emptyList() - } + override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList() } diff --git a/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainActivity.kt b/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainActivity.kt index 4990652..65a1c13 100644 --- a/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainActivity.kt +++ b/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainActivity.kt @@ -6,7 +6,6 @@ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnable import com.facebook.react.defaults.DefaultReactActivityDelegate class MainActivity : ReactActivity() { - /** * Returns the name of the main component registered from JavaScript. This is used to schedule * rendering of the component. @@ -17,6 +16,5 @@ class MainActivity : ReactActivity() { * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] */ - override fun createReactActivityDelegate(): ReactActivityDelegate = - DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) + override fun createReactActivityDelegate(): ReactActivityDelegate = DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) } diff --git a/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainApplication.kt b/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainApplication.kt index 0ec47cc..4e3a606 100644 --- a/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainApplication.kt +++ b/example/android/app/src/main/java/posthogreactnativesessionreplay/example/MainApplication.kt @@ -11,23 +11,24 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost import com.facebook.soloader.SoLoader -class MainApplication : Application(), ReactApplication { - +class MainApplication : + Application(), + ReactApplication { override val reactNativeHost: ReactNativeHost = - object : DefaultReactNativeHost(this) { - override fun getPackages(): List = - PackageList(this).packages.apply { - // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) - } + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } - override fun getJSMainModuleName(): String = "index" + override fun getJSMainModuleName(): String = "index" - override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG - override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED - override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED - } + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } override val reactHost: ReactHost get() = getDefaultReactHost(applicationContext, reactNativeHost) diff --git a/ios/PosthogReactNativeSessionReplay.swift b/ios/PosthogReactNativeSessionReplay.swift index f25bc5c..38d36b3 100644 --- a/ios/PosthogReactNativeSessionReplay.swift +++ b/ios/PosthogReactNativeSessionReplay.swift @@ -1,103 +1,149 @@ import PostHog +// Meant for internally logging PostHog related things +private func hedgeLog(_ message: String) { + print("[PostHog] \(message)") +} + @objc(PosthogReactNativeSessionReplay) class PosthogReactNativeSessionReplay: NSObject { + private var config: PostHogConfig? - private var config: PostHogConfig? + @objc(start:withSdkOptions:withSdkReplayConfig:withDecideReplayConfig:withResolver:withRejecter:) + func start( + sessionId: String, sdkOptions: [String: Any], sdkReplayConfig: [String: Any], + decideReplayConfig: [String: Any], resolve: RCTPromiseResolveBlock, + reject _: RCTPromiseRejectBlock + ) { + // we've seen cases where distinctId and anonymousId are not strings, so we need to check and convert them + guard let sessionIdStr = sessionId as? String else { + hedgeLog("Invalid sessionId provided: \(sessionId). Expected a string.") + resolve(nil) + return + } - @objc(start:withSdkOptions:withSdkReplayConfig:withDecideReplayConfig:withResolver:withRejecter:) - func start(sessionId: String, sdkOptions: [String: Any], sdkReplayConfig: [String: Any], decideReplayConfig: [String: Any], resolve:RCTPromiseResolveBlock,reject:RCTPromiseRejectBlock) { - let apiKey = sdkOptions["apiKey"] as? String ?? "" - let host = sdkOptions["host"] as? String ?? PostHogConfig.defaultHost - let debug = sdkOptions["debug"] as? Bool ?? false + let apiKey = sdkOptions["apiKey"] as? String ?? "" + let host = sdkOptions["host"] as? String ?? PostHogConfig.defaultHost + let debug = sdkOptions["debug"] as? Bool ?? false - PostHogSessionManager.shared.setSessionId(sessionId) + PostHogSessionManager.shared.setSessionId(sessionIdStr) - let config = PostHogConfig(apiKey: apiKey, host: host) - config.sessionReplay = true - config.captureApplicationLifecycleEvents = false - config.captureScreenViews = false - config.debug = debug - config.sessionReplayConfig.screenshotMode = true + let config = PostHogConfig(apiKey: apiKey, host: host) + config.sessionReplay = true + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + config.debug = debug + config.sessionReplayConfig.screenshotMode = true - let maskAllTextInputs = sdkReplayConfig["maskAllTextInputs"] as? Bool ?? true - config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs + let maskAllTextInputs = sdkReplayConfig["maskAllTextInputs"] as? Bool ?? true + config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs - let maskAllImages = sdkReplayConfig["maskAllImages"] as? Bool ?? true - config.sessionReplayConfig.maskAllImages = maskAllImages + let maskAllImages = sdkReplayConfig["maskAllImages"] as? Bool ?? true + config.sessionReplayConfig.maskAllImages = maskAllImages - let maskAllSandboxedViews = sdkReplayConfig["maskAllSandboxedViews"] as? Bool ?? true - config.sessionReplayConfig.maskAllSandboxedViews = maskAllSandboxedViews + let maskAllSandboxedViews = sdkReplayConfig["maskAllSandboxedViews"] as? Bool ?? true + config.sessionReplayConfig.maskAllSandboxedViews = maskAllSandboxedViews - let iOSdebouncerDelayMs = sdkReplayConfig["iOSdebouncerDelayMs"] as? Int ?? 1000 - let timeInterval: TimeInterval = Double(iOSdebouncerDelayMs) / 1000.0 - config.sessionReplayConfig.debouncerDelay = timeInterval + let iOSdebouncerDelayMs = sdkReplayConfig["iOSdebouncerDelayMs"] as? Int ?? 1000 + let timeInterval: TimeInterval = Double(iOSdebouncerDelayMs) / 1000.0 + config.sessionReplayConfig.throttleDelay = timeInterval - let captureNetworkTelemetry = sdkReplayConfig["captureNetworkTelemetry"] as? Bool ?? true - config.sessionReplayConfig.captureNetworkTelemetry = captureNetworkTelemetry + let captureNetworkTelemetry = sdkReplayConfig["captureNetworkTelemetry"] as? Bool ?? true + config.sessionReplayConfig.captureNetworkTelemetry = captureNetworkTelemetry - let endpoint = decideReplayConfig["endpoint"] as? String ?? "" - if !endpoint.isEmpty { - config.snapshotEndpoint = endpoint - } + let endpoint = decideReplayConfig["endpoint"] as? String ?? "" + if !endpoint.isEmpty { + config.snapshotEndpoint = endpoint + } + + let distinctId = sdkOptions["distinctId"] as? String ?? "" + let anonymousId = sdkOptions["anonymousId"] as? String ?? "" + + let sdkVersion = sdkOptions["sdkVersion"] as? String ?? "" + + let flushAt = sdkOptions["flushAt"] as? Int ?? 20 + config.flushAt = flushAt + + if !sdkVersion.isEmpty { + postHogSdkName = "posthog-react-native" + postHogVersion = sdkVersion + } - let distinctId = sdkOptions["distinctId"] as? String ?? "" - let anonymousId = sdkOptions["anonymousId"] as? String ?? "" + PostHogSDK.shared.setup(config) - let sdkVersion = sdkOptions["sdkVersion"] as? String ?? "" + self.config = config - let flushAt = sdkOptions["flushAt"] as? Int ?? 20 - config.flushAt = flushAt + guard let storageManager = self.config?.storageManager else { + hedgeLog("Storage manager is not available in the config.") + resolve(nil) + return + } - if !sdkVersion.isEmpty { - postHogSdkName = "posthog-react-native" - postHogVersion = sdkVersion + setIdentify(storageManager, distinctId: distinctId, anonymousId: anonymousId) + + resolve(nil) + } + + @objc(startSession:withResolver:withRejecter:) + func startSession( + sessionId: String, resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock + ) { + // we've seen cases where distinctId and anonymousId are not strings, so we need to check and convert them + guard let sessionIdStr = sessionId as? String else { + hedgeLog("Invalid sessionId provided: \(sessionId). Expected a string.") + resolve(nil) + return + } + PostHogSessionManager.shared.setSessionId(sessionIdStr) + PostHogSDK.shared.startSession() + resolve(nil) } - PostHogSDK.shared.setup(config) - - self.config = config - - setIdentify(self.config?.storageManager, distinctId: distinctId, anonymousId: anonymousId) - - resolve(nil) - } - - @objc(startSession:withResolver:withRejecter:) - func startSession(sessionId: String, resolve:RCTPromiseResolveBlock,reject:RCTPromiseRejectBlock) -> Void { - PostHogSessionManager.shared.setSessionId(sessionId) - PostHogSDK.shared.startSession() - resolve(nil) - } - - @objc(isEnabled:withRejecter:) - func isEnabled(resolve:RCTPromiseResolveBlock,reject:RCTPromiseRejectBlock) -> Void { - let isEnabled = PostHogSDK.shared.isSessionReplayActive() - resolve(isEnabled) - } - - @objc(endSession:withRejecter:) - func endSession(resolve:RCTPromiseResolveBlock,reject:RCTPromiseRejectBlock) -> Void { - PostHogSDK.shared.endSession() - resolve(nil) - } - - @objc(identify:withAnonymousId:withResolver:withRejecter:) - func identify(distinctId: String, anonymousId: String, resolve:RCTPromiseResolveBlock,reject:RCTPromiseRejectBlock) -> Void { - setIdentify(self.config?.storageManager, distinctId: distinctId, anonymousId: anonymousId) - - resolve(nil) - } - - private func setIdentify(_ storageManager: PostHogStorageManager?, distinctId: String, anonymousId: String) { - guard let storageManager = storageManager else { - return + @objc(isEnabled:withRejecter:) + func isEnabled(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) { + let isEnabled = PostHogSDK.shared.isSessionReplayActive() + resolve(isEnabled) } - if !anonymousId.isEmpty { - storageManager.setAnonymousId(anonymousId) + + @objc(endSession:withRejecter:) + func endSession(resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock) { + PostHogSDK.shared.endSession() + resolve(nil) } - if !distinctId.isEmpty { - storageManager.setDistinctId(distinctId) + + @objc(identify:withAnonymousId:withResolver:withRejecter:) + func identify( + distinctId: String, anonymousId: String, resolve: RCTPromiseResolveBlock, + reject _: RCTPromiseRejectBlock + ) { + // we've seen cases where distinctId and anonymousId are not strings, so we need to check and convert them + guard let distinctIdStr = distinctId as? String, + let anonymousIdStr = anonymousId as? String + else { + hedgeLog( + "Invalid distinctId: \(distinctId) or anonymousId: \(anonymousId) provided. Expected strings." + ) + resolve(nil) + return + } + guard let storageManager = config?.storageManager else { + hedgeLog("Storage manager is not available in the config.") + resolve(nil) + return + } + setIdentify(storageManager, distinctId: distinctIdStr, anonymousId: anonymousIdStr) + + resolve(nil) + } + + private func setIdentify( + _ storageManager: PostHogStorageManager, distinctId: String, anonymousId: String + ) { + if !anonymousId.isEmpty { + storageManager.setAnonymousId(anonymousId) + } + if !distinctId.isEmpty { + storageManager.setDistinctId(distinctId) + } } - } } diff --git a/posthog-react-native-session-replay.podspec b/posthog-react-native-session-replay.podspec index de6e2a6..7d9e9fc 100644 --- a/posthog-react-native-session-replay.podspec +++ b/posthog-react-native-session-replay.podspec @@ -16,8 +16,8 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{swift,h,hpp,m,mm,c,cpp}" - # ~> Version 3.20.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '~> 3.20' + # ~> Version 3.21.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '~> 3.21' s.ios.deployment_target = '13.0' s.swift_versions = "5.3"