Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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/dry-planets-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-react-native': minor
---

feat: support session replay sampleRate config
2 changes: 1 addition & 1 deletion examples/example-expo-53/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"expo-system-ui": "~5.0.9",
"expo-web-browser": "~14.2.0",
"posthog-react-native": "*",
"posthog-react-native-session-replay": "^1.2.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-native": "0.79.6",
Expand Down
22 changes: 11 additions & 11 deletions examples/example-expo-53/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"jest-expo": "catalog:",
"metro": "0.83.1",
"@expo/metro-config": "~0.20.0",
"posthog-react-native-session-replay": "^1.2.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "^0.69.1",
Expand All @@ -89,7 +89,7 @@
"expo-device": ">= 4.0.0",
"expo-file-system": ">= 13.0.0",
"expo-localization": ">= 11.0.0",
"posthog-react-native-session-replay": ">= 1.3.0",
"posthog-react-native-session-replay": ">= 1.5.0",
"react-native-device-info": ">= 10.0.0",
"react-native-localize": ">= 3.0.0",
"react-native-navigation": ">= 6.0.0",
Expand Down
32 changes: 30 additions & 2 deletions packages/react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
PostHogCustomStorage,
PostHogSessionReplayConfig,
} from './types'
import { getRemoteConfigBool } from './utils'
import { getRemoteConfigBool, getRemoteConfigNumber, isValidSampleRate } from './utils'
import { withReactNativeNavigation } from './frameworks/wix-navigation'
import { OptionalReactNativeSessionReplay } from './optional/OptionalSessionReplay'
import { ErrorTracking, ErrorTrackingOptions } from './error-tracking'
Expand Down Expand Up @@ -298,7 +298,7 @@ export class PostHog extends PostHogCore {
* Called when remote config has been loaded (from either the remote config endpoint or the flags endpoint).
* Gates error tracking autocapture based on the remote config response.
*
* Session replay config (consoleLogRecordingEnabled, capturePerformance.network_timing) is already
* Session replay config (consoleLogRecordingEnabled, sampleRate, capturePerformance.network_timing) is already
* cached via PostHogPersistedProperty.RemoteConfig and applied at startup in startSessionReplay().
*
* @internal
Expand Down Expand Up @@ -1449,6 +1449,7 @@ export class PostHog extends PostHogCore {
maskAllSandboxedViews = true,
captureLog: localCaptureLog = true,
captureNetworkTelemetry: localCaptureNetworkTelemetry = true,
sampleRate: localSampleRate,
iOSdebouncerDelayMs = defaultThrottleDelayMs,
androidDebouncerDelayMs = defaultThrottleDelayMs,
} = options?.sessionReplayConfig ?? {}
Expand Down Expand Up @@ -1476,23 +1477,50 @@ export class PostHog extends PostHogCore {
'network_timing',
true
)
const remoteSampleRateRaw = getRemoteConfigNumber(cachedRemoteConfig?.sessionRecording, 'sampleRate')

const captureLog = localCaptureLog && remoteConsoleLogEnabled
const captureNetworkTelemetry = localCaptureNetworkTelemetry && remoteNetworkTimingEnabled

const localSampleRateValid =
localSampleRate === undefined ? undefined : isValidSampleRate(localSampleRate) ? localSampleRate : undefined
const remoteSampleRateValid =
remoteSampleRateRaw === undefined
? undefined
: isValidSampleRate(remoteSampleRateRaw)
? remoteSampleRateRaw
: undefined

const sampleRate = localSampleRateValid ?? remoteSampleRateValid

if (localCaptureLog && !remoteConsoleLogEnabled) {
this._logger.info('captureLog disabled by remote config (consoleLogRecordingEnabled=false).')
}
if (localCaptureNetworkTelemetry && !remoteNetworkTimingEnabled) {
this._logger.info('captureNetworkTelemetry disabled by remote config (capturePerformance.network_timing=false).')
}
if (localSampleRate !== undefined && localSampleRateValid === undefined) {
this._logger.warn(
`Ignoring invalid sessionReplayConfig.sampleRate '${localSampleRate}'. Expected a number between 0 and 1.`
)
}
if (remoteSampleRateRaw !== undefined && remoteSampleRateValid === undefined) {
this._logger.warn(
`Ignoring invalid remote config sessionRecording.sampleRate '${remoteSampleRateRaw}'. Expected a number between 0 and 1.`
)
}
if (typeof sampleRate === 'number') {
const source = localSampleRateValid !== undefined ? 'local config' : 'remote config'
this._logger.info(`sampleRate set from ${source} (${sampleRate}).`)
}

const sdkReplayConfig = {
maskAllTextInputs,
maskAllImages,
maskAllSandboxedViews,
captureLog,
captureNetworkTelemetry,
sampleRate,
iOSdebouncerDelayMs,
androidDebouncerDelayMs,
throttleDelayMs,
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ export type PostHogSessionReplayConfig = {
* Default: true
*/
captureNetworkTelemetry?: boolean
/**
* Session replay sample rate between 0 and 1
* Local config has precedence over remote config when both are set.
* If undefined, sampling is controlled by remote config (when available).
*/
sampleRate?: number
}

export interface PostHogCustomStorage {
Expand Down
39 changes: 39 additions & 0 deletions packages/react-native/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,42 @@ export function getRemoteConfigBool(
}
return defaultValue
}

/**
* Reads a numeric value from a remote config object field.
*
* Remote config values may be either numbers or numeric strings.
*
* @param field The remote config field (e.g. `response.sessionRecording`)
* @param key The key to read (e.g. `'sampleRate'`)
*/
export function getRemoteConfigNumber(
field: boolean | { [key: string]: JsonType } | undefined,
key: string
): number | undefined {
if (field == null || typeof field === 'boolean' || typeof field !== 'object') {
return undefined
}

const value = field[key]
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed === '') {
return undefined
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}

return undefined
}

/**
* Checks whether a value is a valid session replay sample rate in the inclusive range [0, 1].
*/
export function isValidSampleRate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
}
36 changes: 35 additions & 1 deletion packages/react-native/test/remote-config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getRemoteConfigBool } from '../src/utils'
import { getRemoteConfigBool, getRemoteConfigNumber, isValidSampleRate } from '../src/utils'

describe('getRemoteConfigBool', () => {
it('returns default value when field is undefined', () => {
Expand Down Expand Up @@ -60,3 +60,37 @@ describe('getRemoteConfigBool', () => {
expect(getRemoteConfigBool({}, 'key')).toBe(true)
})
})

describe('getRemoteConfigNumber', () => {
it('returns undefined for missing/invalid fields', () => {
expect(getRemoteConfigNumber(undefined, 'sampleRate')).toBeUndefined()
expect(getRemoteConfigNumber(false, 'sampleRate')).toBeUndefined()
expect(getRemoteConfigNumber({}, 'sampleRate')).toBeUndefined()
expect(getRemoteConfigNumber({ sampleRate: 'abc' }, 'sampleRate')).toBeUndefined()
expect(getRemoteConfigNumber({ sampleRate: '' }, 'sampleRate')).toBeUndefined()
expect(getRemoteConfigNumber({ sampleRate: ' ' }, 'sampleRate')).toBeUndefined()
})

it('returns numeric value from number', () => {
expect(getRemoteConfigNumber({ sampleRate: 0.5 }, 'sampleRate')).toBe(0.5)
})

it('returns numeric value from numeric string', () => {
expect(getRemoteConfigNumber({ sampleRate: '0.5' }, 'sampleRate')).toBe(0.5)
})
})

describe('isValidSampleRate', () => {
it('returns true only for finite values in [0, 1]', () => {
expect(isValidSampleRate(0)).toBe(true)
expect(isValidSampleRate(0.5)).toBe(true)
expect(isValidSampleRate(1)).toBe(true)

expect(isValidSampleRate(-0.1)).toBe(false)
expect(isValidSampleRate(1.1)).toBe(false)
expect(isValidSampleRate(Number.POSITIVE_INFINITY)).toBe(false)
expect(isValidSampleRate(Number.NaN)).toBe(false)
expect(isValidSampleRate('0.5')).toBe(false)
expect(isValidSampleRate(null)).toBe(false)
})
})
Loading