Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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