@anyreplay/react-native
v0.3.0
Published
Session replay for React Native: records the committed element tree, not pixels, and ships it to AnyReplay.
Maintainers
Readme
@anyreplay/react-native
Session replay for React Native apps.
npm install @anyreplay/react-nativeimport { AnyReplay, init, trackNavigation } from '@anyreplay/react-native';
init({ projectKey: 'ar_pk_live_…' });
export default function Root() {
return (
<AnyReplay>
<NavigationContainer ref={(ref) => { if (ref) trackNavigation(ref); }}>
<App />
</NavigationContainer>
</AnyReplay>
);
}No native module and no rebuild, so it works in Expo Go as well as in development and production builds.
Requirements: React 18+ and React Native 0.72+, with the New
Architecture (Fabric) — on by default from React Native 0.76 and Expo SDK 52,
Expo Go included. The recorder measures what is on screen through Fabric; on
the old architecture it logs one warning (recording needs the New
Architecture (Fabric)) and records taps, screen names and events, but no
picture of the screen.
What it records, and why not screenshots
Every other mobile replay tool records the screen as images — PostHog and Sentry both do on React Native specifically, because the JavaScript side cannot reach the native view tree without a native module.
This one records structure and words instead. A screenshot is pixels, and pixels cannot be translated; a tool whose reason to exist is showing a session in a language the reviewer reads cannot throw the words away on the way in.
So the recorder reads the layer JavaScript can reach: the tree React has committed. On each tick (every 500 ms by default) it walks that tree and notes, for each host element, what it is and what it says — then measures through Fabric where it sits on screen. React knows what is on screen; the measurement knows where it is; neither alone is enough.
The result is a wireframe, not a photograph. That is honest about what was captured, and it is what makes a Turkish session watchable in English.
It is also what makes it cheap. Smartlook reports 0.5 MB per minute at two screenshots a second, UXCam 50–500 KB. Here the first frame of a screen is a few kilobytes and every frame after it carries only what changed — often a handful of numbers — and an idle screen sends nothing at all.
Privacy
By default a recording does not contain what a person types into a
TextInput, the same as the browser SDK: every value is replaced with
asterisks on the device, so the replay shows the field being filled in without
showing what went into it. To see what people typed, opt out:
maskAllInputs: false—TextInputvalues are recorded (the fields below stay masked).maskAllTyping: true— everyTextInputmasked, and not weakened bymaskAllInputs: falseset elsewhere in your configuration.testIDcontainingar-mask— one view, or everything below it.maskImages: true— no image URLs at all.
Some fields are masked in every mode and no option records them:
| Masked without being asked | Read from |
|---|---|
| A secure field | secureTextEntry |
| A password, new password or one-time code | textContentType, autoComplete, keyboardType: 'visible-password' |
| A card number, security code, expiry or cardholder name | textContentType: 'creditCard…', autoComplete: 'cc-…' |
| A field whose label, placeholder or testID names one of the above | placeholder, accessibilityLabel, testID, nativeID |
| A value of 13–19 digits that passes the card-number check | the value itself |
| A card number still being typed: digits only, starting 2–6, grouped like a card, more than six digits | the value itself — at most the first six digits are ever recorded |
| A card number inside longer text — a note, a message, a multi-line field | its digits, as soon as a seventh is typed; the field and the rest of its text stay recorded |
A masked element keeps its box and loses its words: the reviewer sees that something was typed, and where, and never what. The value is replaced on the device — it is not sent and then hidden.
Where a phone knows less than a browser
A browser field carries type,
autocomplete and a name the framework wrote, so the floor almost always has
something to read. In React Native a <TextInput /> with no props says nothing
about itself at all, and there is no name attribute to fall back on. With
maskAllInputs: false such a field is recorded, consistently with the web,
and only the card-number check on its value can catch it.
Two more differences worth knowing:
- A post code is not payment data here, and neither is an email address or a
phone number —
textContentType: 'postalCode','emailAddress'and'telephoneNumber'followmaskAllInputslike any other field. The browser SDK treats them the same way. - There is no
contenteditableon a phone, somaskAllTypingcovers exactly whatmaskAllInputscovers. What it adds is that it cannot be weakened.
If you opt out and some screens carry anything you would not want a teammate
to read, give those fields a textContentType, an autoComplete or a testID
that says what it is.
Options
| | Default | |
|---|---|---|
| projectKey | — | From the dashboard. Required. |
| ingestUrl | https://in.anyreplay.com | Self-hosted deployments only. |
| sampleRate | 1 | Decided once per visitor, so a recorded person stays recorded. |
| snapshotIntervalMs | 500 | The main cost lever. Slower is cheaper and coarser. |
| maskAllInputs | true | Mask every TextInput. false records typed values; the floor above applies either way. |
| maskAllTyping | false | The same, and maskAllInputs: false no longer weakens it. |
| maskTestID | ar-mask | Views whose testID contains this are masked. |
| maskImages | false | Record no image URLs at all. |
| requireConsent | false | Record nothing until consent(true). |
| storage | in memory | Give it AsyncStorage to recognise a returning visitor. |
| appId | — | Your bundle id / package name. Needed once the project lists allowed domains; add it under Allowed apps too. |
| appVersion | — | Your app's version, shown with each session. React Native cannot read it without a native module. |
| recordErrors | true | Uncaught errors, with stacks, through ErrorUtils. Your own handler still runs. |
| recordConsole | true | console.error and console.warn; never log, info or debug. |
| maxEventsPerChunk | 200 | Send a chunk when this many events are buffered. |
| flushIntervalMs | 5000 | Send a chunk at least this often. |
| maxBufferedEvents | 2000 | Kept while offline; the oldest are dropped beyond it. |
| debug | false | Log warnings: an unmounted <AnyReplay>, a refused request. |
maxConsecutiveFailures is still accepted so older code compiles, and ignored
since 0.2.0: a failed delivery never stops the recording.
import AsyncStorage from '@react-native-async-storage/async-storage';
init({ projectKey: '…', storage: AsyncStorage });An app sends no web origin, so a project that lists allowed domains refuses it
until the app is listed under Allowed apps in the project's settings and
appId names it. The id is a filter, not a secret — anyone with the public
key can send one — so keep relying on quotas and rate limits, not on it.
import * as Application from 'expo-application';
init({
projectKey: '…',
appId: Application.applicationId ?? undefined, // com.example.shop
appVersion: Application.nativeApplicationVersion ?? undefined,
});Each session reports platform: 'react-native', this package's name and
version, and on Android the device model.
API
| | |
|---|---|
| init(options) | Starts recording. Safe to call twice; the second is ignored. |
| stop() | Stops, and detaches everything. |
| consent(granted) | Grants or withdraws consent. |
| identify({ userId, email }) | Attaches an identity to the session. Held until the session's first chunk is accepted, then sent once. |
| screen(name) | A screen change, if you are not using trackNavigation. |
| track(name, properties?) | Tags this moment, as in the browser SDK: name 1–64 chars of [A-Za-z0-9_.:-], properties a JSON object ≤ 4 KB. |
| trackError(error) | Records an error you caught yourself. |
| trackNavigation(ref) | Follows a react-navigation container. |
| flush() | Sends what is buffered. |
| getSessionId() | The current session id, for correlating with your own logs. |
Taps, delivery and sessions
- A tap is recorded once, when the finger lifts, and only if it moved less
than 10 points — a scroll, a swipe or a drag is not a tap.
<AnyReplay>listens to touches in the capture phase (onTouchStartCaptureand friends); it never takes part in the responder system, so it cannot claim a gesture. - Outages are waited out, not given up on. A failed delivery (no network,
a 5xx, a 429) is retried after 2 s, then 4 s, 8 s … at most a minute, with
jitter, and at least as long as
Retry-Afterasks; coming back to the foreground tries again at once. Up tomaxBufferedEventsare kept, and after an outage they leave as several chunks of at most 200 events and about 256 KB each. If the oldest have to be dropped, the next thing sent is a fresh screen size, screen name and full snapshot. - A refusal stops the recording for the launch: 402, 403 and other 4xx. After a 402 (a quota or a session cap) the app does not try again for two minutes, across launches.
- Back after 30 idle minutes is a new session, as a relaunch would be.
- Rotation sends the new screen size and a whole screen.
- iPads and Android tablets report themselves as tablets.
What it does not do yet
- Lists are recorded as what is on screen. A
FlatListreports the rows it has rendered, so scrolling back through a replay shows the rows that existed at that moment, not the whole list. - Images by URL or bundled only. An image loaded from a URL is recorded by
its URL; an image shipped in the app bundle is uploaded once per project, by
content hash. Photos from the device's caches or photo library are never
read, and
maskImages: truerecords no image at all. - Custom native components are recorded as
Unknown— a box in the right place with no text, because there is nothing readable to capture.
