Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rn-plugin-app-language.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion packages/react-native-plugin/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <projectToken>`, 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.
Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/react-native-plugin/ios/PosthogReactNativePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions packages/react-native-plugin/src/__tests__/app-language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* oxlint-disable compat/compat */
const mock = vi.hoisted(() => ({
nativeModule: {} as { setAppLanguage?: (language: string | null) => Promise<void> },
}))

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()
})
})
20 changes: 20 additions & 0 deletions packages/react-native-plugin/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,23 @@ export function getSessionReplayDebugProperties(): Promise<PostHogReactNativePlu
return PosthogReactNativePlugin.getSessionReplayDebugProperties()
}

/**
* Sets the language the app displays, which the native SDK sends as `$app_language` on the
* events it captures itself, such as crashes and push opens. Pass `null` to clear it.
*
* The plugin turns off the native SDK's own detection, since React Native apps usually translate
* in JS, so native events carry no `$app_language` until this is called.
*
* @internal Used by `posthog-react-native`'s `setAppLanguage()`. Not part of the public API.
*/
export function setAppLanguage(language: string | null): Promise<void> {
// 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<void> {
return PosthogReactNativePlugin.registerPushNotificationToken(deviceToken, appId)
}
Expand Down Expand Up @@ -265,6 +282,8 @@ export interface PostHogReactNativePluginModule {

getSessionReplayDebugProperties: () => Promise<PostHogReactNativePluginMap>

setAppLanguage: (language: string | null) => Promise<void>

registerPushNotificationToken: (deviceToken: string, appId: string | null) => Promise<void>

unregisterPushNotificationToken: () => Promise<void>
Expand All @@ -289,6 +308,7 @@ const PostHogReactNativePlugin: PostHogReactNativePluginModule = {
captureFatalException,
markFatalExceptionHandled,
getSessionReplayDebugProperties,
setAppLanguage,
registerPushNotificationToken,
unregisterPushNotificationToken,
setOptOut,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4179,6 +4179,10 @@
"type": "(() => Promise<PostHogReactNativePluginMap>) & (() => Promise<{ [key: string]: any; }>)",
"name": "getSessionReplayDebugProperties"
},
{
"type": "(language: string | null) => Promise<void>",
"name": "setAppLanguage"
},
{
"type": "((deviceToken: string, appId: string | null) => Promise<void>) & ((deviceToken: string, appId: string | null) => Promise<void>)",
"name": "registerPushNotificationToken"
Expand Down
Loading