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-set-app-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-react-native': minor
---

Add `setAppLanguage()` to send the language your app displays as `$app_language` on every event
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,35 @@
},
"path": "dist/posthog-rn.d.ts"
},
{
"category": "Capture",
"description": "Sets the `$app_language` property: the language your app displays, sent with every event.\nDefault: not set, so events carry no `$app_language`. React Native apps usually translate in JavaScript (for example with i18next), which the SDK can't see, so call this at startup and whenever the language changes. Compare `$locale`, which is the device language.\nThe value is kept in memory only and survives `reset()`. A `$app_language` you `register()` wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK captures itself, such as crashes and push opens, carry it too. Call it right after creating the client so a push open that launched the app carries it as well.",
"details": null,
"id": "setAppLanguage",
"showDocs": true,
"title": "setAppLanguage",
"examples": [
{
"id": "with_i18next_or_react-i18next",
"name": "With i18next or react-i18next",
"code": "\n\n// With i18next or react-i18next\nposthog.setAppLanguage(i18n.resolvedLanguage)\ni18n.on('languageChanged', (language) => posthog.setAppLanguage(language))\n\n// Your own language setting\nposthog.setAppLanguage(settings.language) // at startup\nposthog.setAppLanguage('de') // when the user picks German\n\n\n\n"
}
],
"releaseTag": "public",
"params": [
{
"description": "A BCP 47 language tag, such as `de` or `pt-BR`. `_` is replaced with `-`. Pass `null` or an empty string to stop sending it.",
"isOptional": false,
"type": "string | null",
"name": "language"
}
],
"returnType": {
"id": "void",
"name": "void"
},
"path": "dist/posthog-rn.d.ts"
},
{
"category": "Feature flags",
"description": "Sets group properties for feature flag evaluation.\nThese properties are automatically attached to the current group (set via posthog.group()). When you change the group, these properties are reset.",
Expand Down Expand Up @@ -4203,6 +4232,10 @@
{
"type": "((provider: PostHogPushIdentityProvider) => void) & ((provider: PostHogPushIdentityProvider) => void)",
"name": "setPushIdentityProvider"
},
{
"type": "(language: string | null) => Promise<void>",
"name": "setAppLanguage"
}
],
"path": "dist/optional/OptionalPlugin.d.ts"
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/src/optional/OptionalPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type PostHogReactNativePluginExtended = typeof PostHogReactNativePlugin &
captureFatalException?: (distinctId: string, timestamp: string, properties: { [key: string]: any }) => Promise<void>
getSessionReplayDebugProperties?: () => Promise<{ [key: string]: any }>
markFatalExceptionHandled?: (message: string) => Promise<void>
setAppLanguage?: (language: string | null) => Promise<void>
}

export type OptionalPluginLoaders = {
Expand Down
45 changes: 45 additions & 0 deletions packages/react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export class PostHog extends PostHogCore {
private _eventsStorage: PostHogRNStorage
private _logsStorage: PostHogRNStorage
private _appProperties: PostHogCustomAppProperties = {}
private _appLanguage: string | undefined
private _currentSessionId?: string | undefined
private _enableSessionReplay?: boolean
private _sessionReplayNativeInitialized: boolean = false
Expand Down Expand Up @@ -866,6 +867,11 @@ export class PostHog extends PostHogCore {
return {
...super.getCommonEventProperties(),
...this._appProperties,
// A registered $app_language wins, as on the native SDKs. Common properties override
// registered and session ones, so skip ours rather than overwrite it.
...(this._appLanguage !== undefined &&
this.props.$app_language === undefined &&
this.sessionProps.$app_language === undefined && { $app_language: this._appLanguage }),
$screen_height: height,
$screen_width: width,
}
Expand Down Expand Up @@ -1080,6 +1086,42 @@ export class PostHog extends PostHogCore {
return this._overrideDisplayLanguage
}

/**
* Sets the `$app_language` property: the language your app displays, sent with every event.
*
* Default: not set, so events carry no `$app_language`. React Native apps usually translate in
* JavaScript (for example with i18next), which the SDK can't see, so call this at startup and
* whenever the language changes. Compare `$locale`, which is the device language.
*
* The value is kept in memory only and survives `reset()`. A `$app_language` you `register()`
* wins over it. With `@posthog/react-native-plugin` 3.0 or later, events the native SDK
* captures itself, such as crashes and push opens, carry it too. Call it right after creating
* the client so a push open that launched the app carries it as well.
*
* {@label Capture}
*
* @example
* ```js
* // With i18next or react-i18next
* posthog.setAppLanguage(i18n.resolvedLanguage)
* i18n.on('languageChanged', (language) => posthog.setAppLanguage(language))
*
* // Your own language setting
* posthog.setAppLanguage(settings.language) // at startup
* posthog.setAppLanguage('de') // when the user picks German
* ```
*
* @public
*
* @param language A BCP 47 language tag, such as `de` or `pt-BR`. `_` is replaced with `-`.
* Pass `null` or an empty string to stop sending it.
*/
setAppLanguage(language: string | null): void {
const normalized = typeof language === 'string' ? language.trim().replace(/_/g, '-') : ''
this._appLanguage = normalized || undefined
void this._enqueueNative('setAppLanguage', (plugin) => plugin.setAppLanguage?.(this._appLanguage ?? null))
}

/**
* Registers super properties that are sent with every event.
*
Expand Down Expand Up @@ -3327,6 +3369,9 @@ export class PostHog extends PostHogCore {
// Native-sent requests (session replay, crash uploads) bypass the JS request path,
// so the configured headers are passed through to the native plugin as well.
requestHeaders: this._requestHeaders,
// Applied before native setup, so the push open setup captures on a cold launch carries it.
// null clears a value an earlier client left on the process-wide native SDK.
appLanguage: this._appLanguage ?? null,
}

// Log header names only: requestHeaders values can carry secrets (e.g. an Authorization token).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ exports[`PostHog RN session replay request headers > snapshots the complete lega
"sdkOptions": {
"anonymousId": "<anonymous-id>",
"apiKey": "test-token",
"appLanguage": null,
"debug": false,
"distinctId": "<anonymous-id>",
"flushAt": 20,
Expand Down Expand Up @@ -74,6 +75,7 @@ exports[`PostHog RN session replay request headers > snapshots the complete nati
"sdkOptions": {
"anonymousId": "replay-snapshot-id",
"apiKey": "test-token",
"appLanguage": null,
"debug": false,
"distinctId": "replay-snapshot-id",
"flushAt": 20,
Expand Down
176 changes: 176 additions & 0 deletions packages/react-native/test/app-language.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { PostHog, PostHogCustomStorage, PostHogOptions } from '../src'
import { OptionalReactNativePlugin } from '../src/optional/OptionalPlugin'
import { Linking, AppState } from 'react-native'
import { waitForExpect, wait } from './test-utils'

vi.mock('../src/optional/OptionalPlugin', () => ({
OptionalReactNativePluginVersion: '3.0.0',
OptionalReactNativePlugin: {
setup: vi.fn(async () => {}),
setOptOut: vi.fn(async () => {}),
startSession: vi.fn(async () => {}),
endSession: vi.fn(async () => {}),
isEnabled: vi.fn(async () => false),
identify: vi.fn(async () => {}),
setAppLanguage: vi.fn(async () => {}),
registerPushNotificationToken: vi.fn(async () => {}),
},
}))

const native = OptionalReactNativePlugin as unknown as Record<'setup' | 'setAppLanguage', vi.Mock | undefined>

Linking.getInitialURL = vi.fn(() => Promise.resolve(null))
AppState.addEventListener = vi.fn(() => ({ remove: () => {} })) as any

describe('setAppLanguage', () => {
vi.useRealTimers()

let clients: PostHog[] = []
let storage: PostHogCustomStorage
const setAppLanguageMock = native.setAppLanguage!

const newClient = async (apiKey = 'test-token', options?: PostHogOptions): Promise<PostHog> => {
const client = new PostHog(apiKey, { customStorage: storage, flushInterval: 0, ...options })
clients.push(client)
await client.ready()
return client
}

const captured = async (client: PostHog, properties?: { [key: string]: any }): Promise<any> => {
const onCapture = vi.fn()
client.on('capture', onCapture)
client.capture('event', properties)
await waitForExpect(200, () => expect(onCapture).toHaveBeenCalled())
return onCapture.mock.calls[0][0].properties
}

beforeEach(() => {
native.setAppLanguage = setAppLanguageMock
setAppLanguageMock.mockClear()
native.setup!.mockClear()
;(globalThis as any).window.fetch = vi.fn(async (url: string) => {
const res = url.includes('flags') ? { featureFlags: {}, sessionRecording: { endpoint: '/s/' } } : { status: 'ok' }
return { status: 200, json: () => Promise.resolve(res) }
})
const cache: { [key: string]: string } = {}
storage = {
getItem: async (key) => cache[key] || null,
setItem: async (key, value) => {
cache[key] = value
},
}
})

afterEach(async () => {
await Promise.all(clients.map((c) => c.shutdown()))
clients = []
})

it('sends no $app_language by default', async () => {
const client = await newClient()

expect(await captured(client)).not.toHaveProperty('$app_language')
})

it.each([
{ input: 'de', expected: 'de' },
{ input: ' pt_BR ', expected: 'pt-BR' },
{ input: 'zh_Hant_TW', expected: 'zh-Hant-TW' },
])('stamps $input as $expected', async ({ input, expected }) => {
const client = await newClient()

client.setAppLanguage(input)

expect((await captured(client)).$app_language).toBe(expected)
})

it.each([null, '', ' ', 42 as unknown as string])('clears it when given %p', async (input) => {
const client = await newClient()
client.setAppLanguage('de')

client.setAppLanguage(input)

expect(await captured(client)).not.toHaveProperty('$app_language')
})

it('survives reset()', async () => {
const client = await newClient()
client.setAppLanguage('de')

client.reset()

expect((await captured(client)).$app_language).toBe('de')
})

it('loses to a registered or captured $app_language', async () => {
const client = await newClient()
client.setAppLanguage('de')
await client.register({ $app_language: 'fr' })

expect((await captured(client)).$app_language).toBe('fr')
expect((await captured(client, { $app_language: 'es' })).$app_language).toBe('es')
})

it('loses to a session-registered $app_language', async () => {
const client = await newClient()
client.setAppLanguage('de')
client.registerForSession({ $app_language: 'fr' })

expect((await captured(client)).$app_language).toBe('fr')
})

it('forwards a language set before native setup once setup runs', async () => {
const client = await newClient('test-token', {
persistence: 'memory',
capturePushNotificationSubscriptions: false,
capturePushNotificationOpened: false,
})
client.setAppLanguage('de')
await wait(50)
expect(setAppLanguageMock).not.toHaveBeenCalled()

await client.registerPushNotificationToken('device-token')

expect(native.setup!.mock.calls[0][1]).toMatchObject({ appLanguage: 'de' })
})

it('clears the native language on setup when none is set', async () => {
await newClient('test-token', { enableSessionReplay: true })

await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled())
expect(native.setup!.mock.calls[0][1]).toMatchObject({ appLanguage: null })
})

it('forwards changes and clears to native after setup', async () => {
const client = await newClient('test-token', { enableSessionReplay: true })
await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled())

client.setAppLanguage('pt_BR')
await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenLastCalledWith('pt-BR'))
client.setAppLanguage(null)
await waitForExpect(2000, () => expect(setAppLanguageMock).toHaveBeenLastCalledWith(null))
})

it("keeps another project's language off the native SDK", async () => {
await newClient('test-token', { enableSessionReplay: true })
await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled())
const other = await newClient('other-project-token', { enableSessionReplay: true })
await wait(50)

other.setAppLanguage('de')
await wait(50)

expect(setAppLanguageMock).not.toHaveBeenCalled()
expect((await captured(other)).$app_language).toBe('de')
})

it('still stamps JS events with a plugin that predates setAppLanguage', async () => {
native.setAppLanguage = undefined
const client = await newClient('test-token', { enableSessionReplay: true })
await waitForExpect(2000, () => expect(native.setup).toHaveBeenCalled())

client.setAppLanguage('de')

expect((await captured(client)).$app_language).toBe('de')
})
})
Loading