diff --git a/.changeset/rn-plugin-app-language.md b/.changeset/rn-plugin-app-language.md new file mode 100644 index 0000000000..4763390024 --- /dev/null +++ b/.changeset/rn-plugin-app-language.md @@ -0,0 +1,5 @@ +--- +'@posthog/react-native-plugin': minor +--- + +Send the language set with `posthog-react-native`'s `setAppLanguage()` as `$app_language` on native events, instead of the language the native SDK detects diff --git a/packages/react-native-plugin/MIGRATION.md b/packages/react-native-plugin/MIGRATION.md index 7596f53187..a78999795b 100644 --- a/packages/react-native-plugin/MIGRATION.md +++ b/packages/react-native-plugin/MIGRATION.md @@ -138,12 +138,24 @@ Native uploads, such as session replay and native crash reports, pause while cel - The proxy must pass `Authorization` and `Content-Encoding` through unchanged. A proxy set up from PostHog's [proxy guides](https://posthog.com/docs/advanced/proxy) before 29 October 2026 strips `Authorization`, so PostHog answers `401` and drops the native events. Update it from the current guide. PostHog's managed reverse proxy needs no change. - These requests send `Authorization: Bearer `, which replaces any `Authorization` header you set in `requestHeaders`. Session replay requests still send yours, so authenticate your proxy with a different header, such as `X-Proxy-Token`. - A proxy that redirects these requests, other than with a 307 or 308 to the same origin as `host`, drops them. -- These events report `$locale` as a full language tag with region, such as `de-CH` or `zh-Hant-TW` instead of `de` or `zh-TW`, and add `$app_language`, the language your app shows. +- These events report `$locale` as a full language tag with region, such as `de-CH` or `zh-Hant-TW` instead of `de` or `zh-TW`, and can carry `$app_language` (see below). See [Capture v1](https://github.com/PostHog/posthog-ios/blob/main/MIGRATION.md#capture-v1) and [`$locale` and `$app_language`](https://github.com/PostHog/posthog-ios/blob/main/MIGRATION.md#locale-and-app_language) in the `posthog-ios` migration guide, and [Endpoints](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md#endpoints) and [`$locale` and `$app_language`](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md#locale-and-app_language) in the `posthog-android` one. **If ignored:** a proxy that answers `404` gets a retry about every 30 seconds from each running app, and the native events stay queued on the device only until a full queue evicts them (1000 events by default), oldest first. Other errors, such as `401`, `403` or `405`, drop them. +### Native events carry `$app_language` only after you set it + +The plugin turns off the language detection in `posthog-ios` 4.0 and `posthog-android` 4.0, because React Native apps usually translate in JavaScript and the native SDKs can't see that language. Native events carry `$app_language` only after you call `posthog.setAppLanguage()` from `posthog-react-native` 4.86.0 or later. Call it at startup and whenever the language changes. The value also goes on JavaScript events. + +```ts +posthog.setAppLanguage(i18n.language) +``` + +If your app creates PostHog clients for more than one project, native events take the language only from clients of the project the native SDK was set up for. The other clients still add it to their own JavaScript events. + +**If ignored:** native events and JavaScript events carry no `$app_language`, as with 2.x. + ### Native surveys are left out of full Swift Package Manager apps Apps on React Native's full Swift Package Manager integration no longer bundle the native surveys from `posthog-ios`. The plugin doesn't use them, because surveys render in JavaScript. The CocoaPods path still includes the full SDK. @@ -165,4 +177,5 @@ Apps on React Native's full Swift Package Manager integration no longer bundle t - [ ] Direct `start()` calls now use `setup()`, and direct `setup()` calls pass `throttleDelayMs`. - [ ] `screenshotModeBackgroundCapture` is removed. - [ ] Reverse proxies and self-hosted instances serve `/i/v1/*`, proxies pass `Authorization` and `Content-Encoding` through and don't authenticate with `Authorization`, and they don't redirect capture requests to another origin. +- [ ] If you want `$app_language`, your app calls `posthog.setAppLanguage()`. - [ ] Native code and native libraries that call `posthog-ios` or `posthog-android` directly are updated for 4.0. diff --git a/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt b/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt index e525c820fc..a5ef922673 100644 --- a/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt +++ b/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt @@ -132,6 +132,9 @@ class PosthogReactNativePluginModule( // answer, not a default the SDK may override from its own persisted copy. persistOptOut = false preloadFeatureFlags = thePreloadFeatureFlags + // React Native apps usually translate in JS, so the resources' locale often isn't + // the language on screen. JS reports the language it displays through setAppLanguage. + detectAppLanguage = false captureDeepLinks = false captureApplicationLifecycleEvents = false captureScreenViews = false @@ -206,6 +209,9 @@ class PosthogReactNativePluginModule( sdkVersion = theSdkVersion } } + // Set before setup so the cold-start push open captured below carries it. The shared + // instance keeps it across setup, and an empty value clears one an earlier JS client left. + PostHog.setAppLanguage(getString(sdkOptions, "appLanguage", "")) PostHogAndroid.setup(context, config) setIdentify(config.cachePreferences, distinctId, anonymousId) @@ -338,6 +344,20 @@ class PosthogReactNativePluginModule( } } + @ReactMethod + fun setAppLanguage( + language: String?, + promise: Promise, + ) { + try { + PostHog.setAppLanguage(language) + } catch (e: Throwable) { + logError("setAppLanguage", e) + } finally { + promise.resolve(null) + } + } + private fun setIdentify( cachePreferences: PostHogPreferences?, distinctId: String, diff --git a/packages/react-native-plugin/ios/PosthogReactNativePlugin.mm b/packages/react-native-plugin/ios/PosthogReactNativePlugin.mm index 184d92fbf5..3aee5c85a4 100644 --- a/packages/react-native-plugin/ios/PosthogReactNativePlugin.mm +++ b/packages/react-native-plugin/ios/PosthogReactNativePlugin.mm @@ -49,6 +49,10 @@ @interface RCT_EXTERN_MODULE(PosthogReactNativePlugin, RCTEventEmitter) withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setAppLanguage:(NSString)language + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(reset:(NSString)distinctId withAnonymousId:(NSString)anonymousId withResolver:(RCTPromiseResolveBlock)resolve diff --git a/packages/react-native-plugin/ios/PosthogReactNativePlugin.swift b/packages/react-native-plugin/ios/PosthogReactNativePlugin.swift index d31214e10c..71bc61ed99 100644 --- a/packages/react-native-plugin/ios/PosthogReactNativePlugin.swift +++ b/packages/react-native-plugin/ios/PosthogReactNativePlugin.swift @@ -426,6 +426,9 @@ public class PosthogReactNativePlugin: RCTEventEmitter { // JS owns flags; it tells us when the native preload would be a duplicate. posthog-ios // has no remoteConfig switch to mirror — it deprecated the option and always loads. config.preloadFeatureFlags = sdkOptions["preloadFeatureFlags"] as? Bool ?? true + // React Native apps usually translate in JS, so the bundle's localization is often just the + // development region. JS reports the language it displays through setAppLanguage instead. + config.detectAppLanguage = false // Forward custom headers (e.g. Authorization for a reverse proxy) so the native SDK // attaches them to the requests it sends directly (session replay, crash uploads). @@ -480,6 +483,9 @@ public class PosthogReactNativePlugin: RCTEventEmitter { } } + // Set before setup so the push open setup captures on a cold launch carries it. The shared + // instance keeps it across setup, and nil clears a value an earlier JS client left. + PostHogSDK.shared.setAppLanguage(sdkOptions["appLanguage"] as? String) PostHogSDK.shared.setup(config) self.config = config @@ -688,6 +694,14 @@ public class PosthogReactNativePlugin: RCTEventEmitter { } } + @objc(setAppLanguage:withResolver:withRejecter:) + func setAppLanguage( + language: String?, resolve: RCTPromiseResolveBlock, reject _: RCTPromiseRejectBlock + ) { + PostHogSDK.shared.setAppLanguage(language) + resolve(nil) + } + @objc(registerPushNotificationToken:withAppId:withResolver:withRejecter:) func registerPushNotificationToken( deviceToken: String, appId: String?, resolve: RCTPromiseResolveBlock, diff --git a/packages/react-native-plugin/src/__tests__/app-language.test.ts b/packages/react-native-plugin/src/__tests__/app-language.test.ts new file mode 100644 index 0000000000..88d923bf59 --- /dev/null +++ b/packages/react-native-plugin/src/__tests__/app-language.test.ts @@ -0,0 +1,30 @@ +/* oxlint-disable compat/compat */ +const mock = vi.hoisted(() => ({ + nativeModule: {} as { setAppLanguage?: (language: string | null) => Promise }, +})) + +vi.mock('react-native', () => ({ + NativeModules: { PosthogReactNativePlugin: mock.nativeModule }, + NativeEventEmitter: class {}, + Platform: { OS: 'ios', select: (obj: any) => obj.ios ?? obj.default }, +})) + +import PostHogReactNativePlugin, { setAppLanguage } from '../index' + +const { nativeModule } = mock + +describe('setAppLanguage', () => { + it.each(['de', null])('forwards %p to the native module', async (language) => { + nativeModule.setAppLanguage = vi.fn(() => Promise.resolve()) + + await setAppLanguage(language) + + expect(nativeModule.setAppLanguage).toHaveBeenCalledWith(language) + }) + + it('resolves when the native build lacks the method', async () => { + nativeModule.setAppLanguage = undefined + + await expect(PostHogReactNativePlugin.setAppLanguage('de')).resolves.toBeUndefined() + }) +}) diff --git a/packages/react-native-plugin/src/index.tsx b/packages/react-native-plugin/src/index.tsx index 76cd4a8b04..2a3394566a 100644 --- a/packages/react-native-plugin/src/index.tsx +++ b/packages/react-native-plugin/src/index.tsx @@ -167,6 +167,23 @@ export function getSessionReplayDebugProperties(): Promise { + // An OTA-updated JS bundle can run on a native build that predates this method. + if (typeof PosthogReactNativePlugin.setAppLanguage !== 'function') { + return Promise.resolve() + } + return PosthogReactNativePlugin.setAppLanguage(language) +} + export function registerPushNotificationToken(deviceToken: string, appId: string | null): Promise { return PosthogReactNativePlugin.registerPushNotificationToken(deviceToken, appId) } @@ -265,6 +282,8 @@ export interface PostHogReactNativePluginModule { getSessionReplayDebugProperties: () => Promise + setAppLanguage: (language: string | null) => Promise + registerPushNotificationToken: (deviceToken: string, appId: string | null) => Promise unregisterPushNotificationToken: () => Promise @@ -289,6 +308,7 @@ const PostHogReactNativePlugin: PostHogReactNativePluginModule = { captureFatalException, markFatalExceptionHandled, getSessionReplayDebugProperties, + setAppLanguage, registerPushNotificationToken, unregisterPushNotificationToken, setOptOut, diff --git a/packages/react-native/references/posthog-react-native-references-latest.json b/packages/react-native/references/posthog-react-native-references-latest.json index e5a2d22bcb..6eb586e52a 100644 --- a/packages/react-native/references/posthog-react-native-references-latest.json +++ b/packages/react-native/references/posthog-react-native-references-latest.json @@ -4179,6 +4179,10 @@ "type": "(() => Promise) & (() => Promise<{ [key: string]: any; }>)", "name": "getSessionReplayDebugProperties" }, + { + "type": "(language: string | null) => Promise", + "name": "setAppLanguage" + }, { "type": "((deviceToken: string, appId: string | null) => Promise) & ((deviceToken: string, appId: string | null) => Promise)", "name": "registerPushNotificationToken"