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
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ yarn add @segment/analytics-react-native @segment/sovran-react-native react-nati
npm install --save @segment/analytics-react-native @segment/sovran-react-native react-native-get-random-values @react-native-async-storage/async-storage
```

_Note: `@react-native-async-storage/async-storage` is an optional dependency. If you wish to use your own persistence layer you can use the `storePersistor` option when initializing the client. Make sure you always have a persistor (either by having AsyncStorage package installed or by explicitly passing a value), else you might get unexpected side-effects like multiple 'Application Installed' events. Read more [Client Options](#client-options)_
_Note: `@react-native-async-storage/async-storage` is an optional dependency. If you wish to use your own persistence layer you can use the `storePersistor` option when initializing the client. Make sure you always have a persistor (either by having AsyncStorage package installed or by explicitly passing a value), else you might get unexpected side-effects like multiple 'Application Installed' events. Read more [Client Options](#client-options). ⚠️ AsyncStorage is unencrypted—see [Data Storage & Security](#data-storage--security) before storing sensitive PII._

For iOS, install native modules with:

Expand Down Expand Up @@ -98,14 +98,41 @@ You must pass at least the `writeKey`. Additional configuration options are list
| `trackDeepLinks` | false | Enable automatic tracking for when the user opens the app via a deep link (Note: Requires additional setup on iOS, [see instructions](#ios-deep-link-tracking-setup)). |
| `defaultSettings` | undefined | Settings that will be used if the request to get the settings from Segment fails. Type: [SegmentAPISettings](https://github.com/segmentio/analytics-react-native/blob/c0a5895c0c57375f18dd20e492b7d984393b7bc4/packages/core/src/types.ts#L293-L299) |
| `autoAddSegmentDestination` | true | Set to false to skip adding the SegmentDestination plugin. |
| `storePersistor` | undefined | A custom persistor for the store that `analytics-react-native` leverages. Must match [`Persistor`](https://github.com/segmentio/analytics-react-native/blob/master/packages/sovran/src/persistor/persistor.ts#L1-L18) interface exported from [sovran-react-native](https://github.com/segmentio/analytics-react-native/blob/master/packages/sovran). |
| `storePersistor` | undefined | A custom persistor for the store that `analytics-react-native` leverages. Must match [`Persistor`](https://github.com/segmentio/analytics-react-native/blob/master/packages/sovran/src/persistor/persistor.ts#L1-L18) interface exported from [sovran-react-native](https://github.com/segmentio/analytics-react-native/blob/master/packages/sovran). ⚠️ The default (AsyncStorage) persistor is unencrypted, see [Data Storage & Security](#data-storage--security). |
| `proxy` | undefined | `proxy` is a batch URL to post the events. Enable `useSegmentEndpoint` if proxy domain is provided and you want to append the Segment endpoints automatically. If you want to completely customize the proxy by providing a custom URL, disable `useSegmentEndpoint`. Default value is `false`. |
| `errorHandler` | undefined | Create custom actions when errors happen, see [Handling errors](#handling-errors). |
| `cdnProxy` | undefined | Sets an alternative CDN host for settings retrieval. Enable `useSegmentEndpoint` if `cdnProxy` domain is provided and you want to append the Segment endpoints automatically. <br><br>⚠️ **IMPORTANT NOTE:** _Prior to version 2.20.4, any value provided for `cdnProxy` was automatically appended with `/write-key/settings`. **Starting from v2.20.4**, this behavior has **CHANGED**—the SDK will now behave based on the `useSegmentEndpoint` flag._ Please update your configuration accordingly to avoid unexpected issues. ⚠️ |
| `useSegmentEndpoint` | false | Set to `true` to automatically append the Segment endpoints when using `proxy` or `cdnProxy` to send or fetch settings. This will enable automatic routing to the appropriate endpoints. |

\* The default value of `debug` will be false in production.

### Data Storage & Security

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section tells integrators to swap in a storePersistor with no migration step or switchover warning, which is a breaking change for any app that already shipped.

Each sovran store is keyed independently and only ever read through the configured persistor, so an app adopting this loses all previously persisted AsyncStorage state on the upgrade build:

  • checkInstalledVersion sees previousContext?.app === undefined and fires a spurious "Application Installed" for every existing user (packages/core/src/analytics.ts:697)
  • userInfo is regenerated, so every existing user gets a new anonymousId — identity/stitching breaks
  • events queued at the moment of upgrade are dropped

That's the same failure this PR warns about 76 lines earlier (the new note at README.md:35 about "multiple 'Application Installed' events"), so it'd be good not to walk readers straight into it. At minimum a callout here; ideally the snippet reads the existing AsyncStorage key once and seeds the encrypted store before switching over.


By default, `analytics-react-native` persists its state—`userId`, `identify` traits, and the queue of events pending upload—to disk using [`@react-native-async-storage/async-storage`](https://github.com/react-native-async-storage/async-storage). **This default persistor stores data as plaintext JSON with no encryption.** On a rooted/jailbroken device, via an ADB backup, or through forensic extraction, that data—including any PII passed to `identify` or `track` calls—can be read.

If your app handles sensitive PII (email, name, government IDs, etc.), supply your own `storePersistor` backed by encrypted storage, such as the OS Keychain/Keystore via [`react-native-encrypted-storage`](https://github.com/emeraldsanto/react-native-encrypted-storage) or [`react-native-keychain`](https://github.com/oblador/react-native-keychain). Your persistor must implement the [`Persistor`](https://github.com/segmentio/analytics-react-native/blob/master/packages/sovran/src/persistor/persistor.ts#L1-L18) interface:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The link for "the interface you must implement" points at persistor.ts#L1-L18, but those lines are PersistenceConfig (storeId/saveDelay/persistor/onInitialized). Persistor is lines 20–31, so a reader clicking through lands on the wrong interface.

Same stale anchor on the storePersistor row of the config table at README.md:101, which this PR also touches — worth fixing both to #L20-L31, or pinning a permalink SHA the way the defaultSettings row above it does.


```ts
import EncryptedStorage from 'react-native-encrypted-storage';
import type { Persistor } from '@segment/sovran-react-native';
import { createClient } from '@segment/analytics-react-native';

const EncryptedStoragePersistor: Persistor = {
get: async (key) => {
const value = await EncryptedStorage.getItem(key);
return value ? JSON.parse(value) : undefined;
},
Comment on lines +121 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This get has no try/catch, and unlike the default it replaces a rejecting get doesn't degrade — it permanently wedges the SDK.

JSON.parse(value) throws on any truncated/corrupted blob, and EncryptedStorage.getItem itself can reject: the linked library sets no kSecAttrAccessible, so Keychain defaults to kSecAttrAccessibleWhenUnlocked and access fails with errSecInteractionNotAllowed when the app is launched in the background before first device unlock.

When that happens, packages/sovran/src/store.ts:131 only console.warns and never calls config.persist.onInitialized. So hasRestoredUserInfo/hasRestoredPendingEvents stay false, SegmentClient.init() blocks forever on storageReady() (packages/core/src/analytics.ts:295), isReady never flips, and every subsequent event is parked in pendingEvents and never sent. Storage readiness has no timeout (unlike QueueFlushingPlugin's 1s restoreTimeout), so it's unrecoverable for the life of the process.

AsyncStoragePersistor try/catches both methods and returns undefined, so as written this snippet is strictly less safe than the default:

Suggested change
get: async (key) => {
const value = await EncryptedStorage.getItem(key);
return value ? JSON.parse(value) : undefined;
},
get: async (key) => {
try {
const value = await EncryptedStorage.getItem(key);
return value ? JSON.parse(value) : undefined;
} catch {
return undefined;
}
},

set: async (key, state) => {
await EncryptedStorage.setItem(key, JSON.stringify(state));
},
Comment on lines +125 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set can reject too — a locked-device Keychain write, or a pending-event queue large enough to exceed what the Keychain / EncryptedSharedPreferences write accepts. store.ts:150 swallows it with a console.warn, so integrators who copy this snippet get silent, permanent loss of cross-restart persistence (queued events dropped on app kill) with nothing surfaced to their error handler.

Suggested change
set: async (key, state) => {
await EncryptedStorage.setItem(key, JSON.stringify(state));
},
set: async (key, state) => {
try {
await EncryptedStorage.setItem(key, JSON.stringify(state));
} catch (e) {
console.warn('Failed to persist Segment state', e);
}
},

Worth a line in the prose noting the queue blob can grow large, since that's the realistic trigger.

};

const segmentClient = createClient({
writeKey: 'SEGMENT_API_KEY',
storePersistor: EncryptedStoragePersistor,
});
Comment on lines +130 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth setting storePersistorSaveDelay in this example. QueueFlushingPlugin defaults it to 0 (packages/core/src/plugins/QueueFlushingPlugin.ts:55), so with an encrypted persistor every single tracked event re-serializes the entire pending queue and pushes it through a Keychain/Keystore write — an O(n) native crypto write per event on the hot path, getting worse as the queue grows.

Suggested change
const segmentClient = createClient({
writeKey: 'SEGMENT_API_KEY',
storePersistor: EncryptedStoragePersistor,
});
const segmentClient = createClient({
writeKey: 'SEGMENT_API_KEY',
storePersistor: EncryptedStoragePersistor,
// Batch writes: the default of 0 re-encrypts the whole queue on every event.
storePersistorSaveDelay: 1000,
});

```

### iOS Deep Link Tracking Setup

_Note: This is only required for iOS if you are using the `trackDeepLinks` option. Android does not require any additional setup_
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ export type Config = {
defaultSettings?: SegmentAPISettings;
autoAddSegmentDestination?: boolean;
collectDeviceId?: boolean;
/**
* Custom persistor for the store. When omitted, the SDK defaults to
* AsyncStorage, which persists state (userId, `identify` traits, and the
* pending event queue) as plaintext JSON with no encryption. Apps handling
* sensitive PII should supply a `storePersistor` backed by encrypted
* storage (e.g. Keychain/Keystore via `react-native-encrypted-storage`).
*/
storePersistor?: Persistor;
storePersistorSaveDelay?: number;
proxy?: string;
Expand Down
9 changes: 8 additions & 1 deletion packages/sovran/src/persistor/async-storage-persistor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ function warnIfMissingPackage() {
}

/**
* Persistor implementation using AsyncStorage
* Persistor implementation using AsyncStorage.
*
* ⚠️ AsyncStorage is unencrypted: state is written as plaintext JSON and is
* readable on rooted/jailbroken devices, via ADB backup, or forensic
* extraction. This includes userId, `identify` traits (email/name/etc.), and
* the pending event queue. Apps handling sensitive PII should pass their own
* `storePersistor` backed by encrypted storage (Keychain/Keystore, e.g. via
* `react-native-encrypted-storage`) instead of relying on this default.
*/
export const AsyncStoragePersistor: Persistor = {
get: async <T>(key: string): Promise<T | undefined> => {
Expand Down
Loading