Skip to content
Merged
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/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
24 changes: 22 additions & 2 deletions examples/example-expo-53/android/settings.gradle
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
pluginManagement {
// Ensure 'node' is on the PATH for all Gradle child processes (e.g. when launched from Android Studio).
// Android Studio doesn't inherit the shell's PATH, so Homebrew/nvm node isn't found.
// Modifying a ProcessBuilder's environment() mutates the global ProcessEnvironment.theEnvironment,
// making 'node' available to all subsequently spawned processes (including Gradle plugins).
def nodeDir = null
for (candidate in ["/opt/homebrew/bin/node", "/usr/local/bin/node", System.getenv("HOME") + "/.nvm/current/bin/node"]) {
if (new File(candidate).exists()) {
nodeDir = new File(candidate).getParent()
break
}
}
if (nodeDir != null) {
def processEnv = new ProcessBuilder().environment()
def currentPath = processEnv.get("PATH") ?: ""
if (!currentPath.contains(nodeDir)) {
processEnv.put("PATH", nodeDir + File.pathSeparator + currentPath)
}
}
def nodeBinary = nodeDir ? nodeDir + "/node" : "node"

def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
commandLine(nodeBinary, "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)

def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
commandLine(nodeBinary, "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
Expand Down
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
2,640 changes: 1,293 additions & 1,347 deletions examples/example-expo-53/pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion examples/example_rn_macos/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ run:
yarn macos

build:
xcodebuild -workspace macos/example_rn_macos.xcworkspace -configuration Debug -scheme example_rn_macos-macOS
set -o pipefail && xcrun xcodebuild -workspace macos/example_rn_macos.xcworkspace -configuration Debug -scheme example_rn_macos-macOS | xcpretty
6 changes: 3 additions & 3 deletions packages/browser/src/extensions/replay/session-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { RemoteConfigLoader } from '../../remote-config'
import { Properties, RemoteConfig, SessionRecordingPersistedConfig, SessionStartReason } from '../../types'
import { type eventWithTime } from './types/rrweb-types'

import { isNullish, isUndefined } from '@posthog/core'
import { isNullish, isNumber, isUndefined, isValidSampleRate } from '@posthog/core'
import { createLogger } from '../../utils/logger'
import {
assignableWindow,
Expand Down Expand Up @@ -152,8 +152,8 @@ export class SessionRecording {
if (isNullish(rate)) {
return null
}
const parsed = parseFloat(rate as string)
if (isNaN(parsed) || parsed < 0 || parsed > 1) {
const parsed = isNumber(rate) ? rate : parseFloat(rate as string)
if (!isValidSampleRate(parsed)) {
logger.warn(`${source} must be between 0 and 1. Ignoring invalid value:`, rate)
return null
}
Expand Down
1 change: 1 addition & 0 deletions packages/browser/terser-mangled-names.json
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@
"_urlTriggerStatus",
"_urlTriggers",
"_validateEmail",
"_validateSampleRate",
"_visibilityChangeListener",
"_visibilityStateListener",
"_widgetRef",
Expand Down
65 changes: 64 additions & 1 deletion packages/core/src/utils/number-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createMockLogger } from '@/testing'
import { clampToRange } from './number-utils'
import { clampToRange, getRemoteConfigBool, getRemoteConfigNumber, isValidSampleRate } from './number-utils'

describe('number-utils', () => {
const mockLogger = createMockLogger()
Expand Down Expand Up @@ -86,4 +86,67 @@ describe('number-utils', () => {
expect(mockLogger.warn).toHaveBeenCalledWith('min cannot be greater than max.')
})
})

describe('getRemoteConfigBool', () => {
it('returns default value when field is undefined or null', () => {
expect(getRemoteConfigBool(undefined, 'key')).toBe(true)
expect(getRemoteConfigBool(undefined, 'key', false)).toBe(false)
expect(getRemoteConfigBool(null as any, 'key')).toBe(true)
})

it('returns the boolean directly when field is boolean', () => {
expect(getRemoteConfigBool(true, 'key')).toBe(true)
expect(getRemoteConfigBool(false, 'key')).toBe(false)
expect(getRemoteConfigBool(false, 'key', true)).toBe(false)
})

it('returns the key value when field is an object with a boolean key', () => {
expect(getRemoteConfigBool({ autocaptureExceptions: true }, 'autocaptureExceptions')).toBe(true)
expect(getRemoteConfigBool({ autocaptureExceptions: false }, 'autocaptureExceptions')).toBe(false)
})

it('returns default value when key is missing or non-boolean', () => {
expect(getRemoteConfigBool({ otherKey: 'value' }, 'autocaptureExceptions')).toBe(true)
expect(getRemoteConfigBool({ otherKey: 'value' }, 'autocaptureExceptions', false)).toBe(false)
expect(getRemoteConfigBool({ autocaptureExceptions: 'yes' }, 'autocaptureExceptions')).toBe(true)
})

it('returns default true for empty object', () => {
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)
})
})
})
69 changes: 68 additions & 1 deletion packages/core/src/utils/number-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Logger } from '../types'
import { JsonType, Logger } from '../types'
import { isNumber } from './type-utils'

/**
Expand Down Expand Up @@ -28,3 +28,70 @@ export function clampToRange(value: unknown, min: number, max: number, logger: L
return value
}
}

/**
* Reads a boolean value from a remote config field.
*
* Remote config fields follow a pattern: they are either a boolean (false = disabled),
* an object with specific keys, or absent/undefined.
*
* @param field The remote config field (e.g., `response.errorTracking`, `response.capturePerformance`)
* @param key The key to read from the object form (e.g., `'autocaptureExceptions'`, `'network_timing'`)
* @param defaultValue Value to return when the field is absent/undefined (defaults to `true` — don't block locally enabled capture)
*/
export function getRemoteConfigBool(
field: boolean | { [key: string]: JsonType } | undefined,
key: string,
defaultValue: boolean = true
): boolean {
if (field == null) {
return defaultValue
}
if (typeof field === 'boolean') {
return field
}
if (typeof field === 'object') {
const value = field[key]
return typeof value === 'boolean' ? value : defaultValue
}
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 !== '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
}
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
32 changes: 3 additions & 29 deletions packages/react-native/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { JsonType } from '@posthog/core'
import { Platform } from 'react-native'

// Re-export remote config utilities from core
export { getRemoteConfigBool, getRemoteConfigNumber, isValidSampleRate } from '@posthog/core'

type ReactNativeGlobal = {
HermesInternal?: {
enablePromiseRejectionTracker?: (args: {
Expand Down Expand Up @@ -41,31 +43,3 @@ export function isWindows(): boolean {
}

export const isHermes = () => !!GLOBAL_OBJ.HermesInternal

/**
* Reads a boolean value from a remote config field.
*
* Remote config fields follow a pattern: they are either a boolean (false = disabled),
* an object with specific keys, or absent/undefined.
*
* @param field The remote config field (e.g., `response.errorTracking`, `response.capturePerformance`)
* @param key The key to read from the object form (e.g., `'autocaptureExceptions'`, `'network_timing'`)
* @param defaultValue Value to return when the field is absent/undefined (defaults to `true` — don't block locally enabled capture)
*/
export function getRemoteConfigBool(
field: boolean | { [key: string]: JsonType } | undefined,
key: string,
defaultValue: boolean = true
): boolean {
if (field == null) {
return defaultValue
}
if (typeof field === 'boolean') {
return field
}
if (typeof field === 'object') {
const value = field[key]
return typeof value === 'boolean' ? value : defaultValue
}
return defaultValue
}
Loading
Loading