@thravic/react-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -2
- package/dist/client.d.ts +96 -0
- package/dist/client.js +423 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +49 -0
- package/dist/react-native.d.ts +2 -0
- package/dist/react-native.js +66 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +2 -0
- package/package.json +40 -4
- package/src/client.ts +449 -0
- package/src/index.ts +56 -0
- package/src/react-native.ts +71 -0
- package/src/types.ts +86 -0
package/README.md
CHANGED
|
@@ -1,3 +1,120 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thravic/react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Thravic analytics for React Native and Expo apps. Track screens, sessions, versions and devices, plus your own events, in the same dashboard as your website.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @thravic/react-native @react-native-async-storage/async-storage
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With Expo, use `npx expo install @thravic/react-native @react-native-async-storage/async-storage`. You don't need any other native modules or a config plugin, and it works in Expo Go.
|
|
12
|
+
|
|
13
|
+
On bare React Native, run `npx pod-install` after installing.
|
|
14
|
+
|
|
15
|
+
## Start tracking
|
|
16
|
+
|
|
17
|
+
Add the app in your Thravic dashboard (**Add property → Mobile app**) to get its tracking ID. Then call `init` once, as early as you can:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { Thravic } from '@thravic/react-native';
|
|
21
|
+
|
|
22
|
+
Thravic.init('TF-1A2B3C4D', {
|
|
23
|
+
bundleId: 'com.acme.shop',
|
|
24
|
+
appVersion: '1.4.0',
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Option | Default | |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `bundleId` | `'app'` | The host of screen URLs (`app://com.acme.shop/Home`). |
|
|
31
|
+
| `appVersion` | none | Shown under **Versions**. In Expo, use `Application.nativeApplicationVersion` from `expo-application`. |
|
|
32
|
+
| `apiUrl` | `https://api.thravic.com` | |
|
|
33
|
+
| `sessionTimeoutMinutes` | `30` | Time in the background after which the next open starts a new session. |
|
|
34
|
+
| `flushIntervalSeconds` | `10` | How often queued events are sent. |
|
|
35
|
+
| `debug` | `false` | Log every event to the console. |
|
|
36
|
+
|
|
37
|
+
## Screens
|
|
38
|
+
|
|
39
|
+
### React Navigation
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
|
|
43
|
+
import { Thravic } from '@thravic/react-native';
|
|
44
|
+
|
|
45
|
+
const navigationRef = createNavigationContainerRef();
|
|
46
|
+
|
|
47
|
+
export default function App() {
|
|
48
|
+
return (
|
|
49
|
+
<NavigationContainer ref={navigationRef} onReady={() => Thravic.trackNavigation(navigationRef)}>
|
|
50
|
+
{/* ... */}
|
|
51
|
+
</NavigationContainer>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Expo Router
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// app/_layout.tsx
|
|
60
|
+
import { Stack, useNavigationContainerRef } from 'expo-router';
|
|
61
|
+
import { useEffect } from 'react';
|
|
62
|
+
import { Thravic } from '@thravic/react-native';
|
|
63
|
+
|
|
64
|
+
Thravic.init('TF-1A2B3C4D', { bundleId: 'com.acme.shop' });
|
|
65
|
+
|
|
66
|
+
export default function Layout() {
|
|
67
|
+
const ref = useNavigationContainerRef();
|
|
68
|
+
useEffect(() => Thravic.trackNavigation(ref), [ref]);
|
|
69
|
+
return <Stack />;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### By hand
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
Thravic.screen('Checkout');
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Events and users
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
Thravic.track('purchase', { amount: 49, currency: 'USD' });
|
|
83
|
+
Thravic.identify('user-42');
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## A/B tests
|
|
87
|
+
|
|
88
|
+
Create a test under Experiments in the dashboard (Pro plan), start it, then ask for the user's version where the screen changes:
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
import { Thravic, useVariant } from '@thravic/react-native';
|
|
92
|
+
|
|
93
|
+
function Paywall() {
|
|
94
|
+
const version = useVariant('new-paywall'); // undefined while loading
|
|
95
|
+
if (version === undefined) return null;
|
|
96
|
+
return version === 'b' ? <NewPaywall /> : <OldPaywall />;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// when the user reaches the test's goal
|
|
100
|
+
Thravic.track('signup');
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Outside a component, `await Thravic.variant('new-paywall')` gives the same answer. You get a version id from the dashboard, or `null` for the original: the user isn't in the test, the test isn't running, the user opted out, or the tests couldn't load within 3 seconds. A user always gets the same version, on the app and on your website. The first answer in each session records that the user saw the test, and the dashboard counts the goals they reach after it.
|
|
104
|
+
|
|
105
|
+
## Privacy
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
Thravic.optOut(); // stops tracking and drops unsent events; remembered across launches
|
|
109
|
+
Thravic.optIn();
|
|
110
|
+
Thravic.isOptedOut();
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The SDK stores a random visitor ID in AsyncStorage. It doesn't read the advertising ID, contacts, location or any other permissioned data. The dashboard gets the country from the request IP and then throws the IP away.
|
|
114
|
+
|
|
115
|
+
## How it works
|
|
116
|
+
|
|
117
|
+
- Screen views are sent as page views on `app://<bundleId>/<Screen>`, so Pages (shown as Screens), paths and funnels work the same way they do for a website.
|
|
118
|
+
- A session starts when the app opens (`app_open`). It ends when the app goes to the background (`session_end`, with the duration and the last screen). Coming back within `sessionTimeoutMinutes` continues the same session.
|
|
119
|
+
- Events are queued on the device and sent in batches of up to 50. They are sent every `flushIntervalSeconds`, when 10 are waiting, and when the app goes to the background. If a send fails, it is retried with backoff, including after a restart. Up to 1,000 events are kept offline.
|
|
120
|
+
- Every event carries the OS, OS version, device model (manufacturer and model on Android; iPhone or iPad on iOS), app version, screen size and language.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Environment, Experiment, NavigationRefLike, ThravicOptions } from './types';
|
|
2
|
+
export declare const VISITOR_KEY = "_tf_vid";
|
|
3
|
+
export declare const QUEUE_KEY = "_tf_queue";
|
|
4
|
+
export declare const OPT_OUT_KEY = "_tf_optout";
|
|
5
|
+
export declare const DEFAULT_API_URL = "https://api.thravic.com";
|
|
6
|
+
/** The collector takes at most 50 events per batch. */
|
|
7
|
+
export declare const MAX_BATCH = 50;
|
|
8
|
+
/** Queued events kept while offline; the oldest go first. */
|
|
9
|
+
export declare const MAX_QUEUE = 1000;
|
|
10
|
+
/** How long `variant` waits for the running tests before answering null. */
|
|
11
|
+
export declare const TESTS_WAIT_MS = 3000;
|
|
12
|
+
/** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
|
|
13
|
+
export declare function generateId(): string;
|
|
14
|
+
/** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
|
|
15
|
+
export declare function unitHash(text: string): number;
|
|
16
|
+
/**
|
|
17
|
+
* The version of `test` for `visitorId`, or null when they are outside its traffic
|
|
18
|
+
* share. Worked out from a hash, so the same user always gets the same version,
|
|
19
|
+
* exactly as the web tracker would give it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assignVariant(visitorId: string, test: Experiment): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
|
|
24
|
+
* URLs, so the dashboard's pages, paths and funnels work for apps as they do for
|
|
25
|
+
* websites. Events are queued on the device and sent in batches; a batch the API
|
|
26
|
+
* has not acknowledged stays queued and is retried, including after a restart.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ThravicClient {
|
|
29
|
+
private env;
|
|
30
|
+
private trackingId;
|
|
31
|
+
private options;
|
|
32
|
+
private initialized;
|
|
33
|
+
private ready;
|
|
34
|
+
/** Calls made before storage has loaded, replayed in order once it has. */
|
|
35
|
+
private pending;
|
|
36
|
+
private visitorId;
|
|
37
|
+
private sessionId;
|
|
38
|
+
private sessionStart;
|
|
39
|
+
private backgroundAt;
|
|
40
|
+
private currentScreen;
|
|
41
|
+
private optedOut;
|
|
42
|
+
private queue;
|
|
43
|
+
private sending;
|
|
44
|
+
private retryAttempt;
|
|
45
|
+
private retryScheduled;
|
|
46
|
+
private persistScheduled;
|
|
47
|
+
private timer;
|
|
48
|
+
private unsubscribeAppState;
|
|
49
|
+
/** This session's running tests, loaded the first time `variant` is asked. */
|
|
50
|
+
private tests;
|
|
51
|
+
/** Tests whose `experiment_view` this session has already sent. */
|
|
52
|
+
private exposed;
|
|
53
|
+
constructor(env: Environment);
|
|
54
|
+
/** Start tracking. Call once, when the app starts; later calls are ignored. */
|
|
55
|
+
init(trackingId: string, options?: ThravicOptions): Promise<void>;
|
|
56
|
+
/** Record a screen view. Automatic with `trackNavigation`; call it yourself otherwise. */
|
|
57
|
+
screen(name: string, params?: Record<string, unknown>): void;
|
|
58
|
+
/** Record a custom event, e.g. `track('purchase', { amount: 49 })`. */
|
|
59
|
+
track(event: string, properties?: Record<string, unknown>): void;
|
|
60
|
+
/** Link this device to your own user id. Sent as the web tracker sends it. */
|
|
61
|
+
identify(userId: string, traits?: Record<string, unknown>): void;
|
|
62
|
+
/**
|
|
63
|
+
* The version of A/B test `key` to show this user: a version id from the
|
|
64
|
+
* dashboard, or null for the original (not in the test, the test is not
|
|
65
|
+
* running, tracking is off, or the tests could not be loaded in time).
|
|
66
|
+
* The first answer in a session records that the user saw the test.
|
|
67
|
+
*/
|
|
68
|
+
variant(key: string): Promise<string | null>;
|
|
69
|
+
/** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
|
|
70
|
+
optOut(): void;
|
|
71
|
+
/** Resume tracking after `optOut`. */
|
|
72
|
+
optIn(): void;
|
|
73
|
+
isOptedOut(): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Record a screen view whenever the focused route changes. Pass the ref from
|
|
76
|
+
* `createNavigationContainerRef()` / `useNavigationContainerRef()` (React
|
|
77
|
+
* Navigation or Expo Router). Returns a function that stops it.
|
|
78
|
+
*/
|
|
79
|
+
trackNavigation(ref: NavigationRefLike): () => void;
|
|
80
|
+
/** Send everything queued now. Resolves when the attempt is done. */
|
|
81
|
+
flush(): Promise<void>;
|
|
82
|
+
/** Stop timers and listeners. For tests and hot reload; apps never need it. */
|
|
83
|
+
shutdown(): void;
|
|
84
|
+
private startSession;
|
|
85
|
+
private background;
|
|
86
|
+
private foreground;
|
|
87
|
+
/** The running tests, or none when they fail to load or take too long. */
|
|
88
|
+
private loadTests;
|
|
89
|
+
private whenReady;
|
|
90
|
+
private screenUrl;
|
|
91
|
+
private enqueue;
|
|
92
|
+
/** Write the queue to storage, at most once a second. */
|
|
93
|
+
private persist;
|
|
94
|
+
private scheduleRetry;
|
|
95
|
+
private debug;
|
|
96
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ThravicClient = exports.TESTS_WAIT_MS = exports.MAX_QUEUE = exports.MAX_BATCH = exports.DEFAULT_API_URL = exports.OPT_OUT_KEY = exports.QUEUE_KEY = exports.VISITOR_KEY = void 0;
|
|
4
|
+
exports.generateId = generateId;
|
|
5
|
+
exports.unitHash = unitHash;
|
|
6
|
+
exports.assignVariant = assignVariant;
|
|
7
|
+
exports.VISITOR_KEY = '_tf_vid';
|
|
8
|
+
exports.QUEUE_KEY = '_tf_queue';
|
|
9
|
+
exports.OPT_OUT_KEY = '_tf_optout';
|
|
10
|
+
exports.DEFAULT_API_URL = 'https://api.thravic.com';
|
|
11
|
+
/** The collector takes at most 50 events per batch. */
|
|
12
|
+
exports.MAX_BATCH = 50;
|
|
13
|
+
/** Queued events kept while offline; the oldest go first. */
|
|
14
|
+
exports.MAX_QUEUE = 1000;
|
|
15
|
+
/** Send without waiting for the timer once this many are queued. */
|
|
16
|
+
const FLUSH_AT = 10;
|
|
17
|
+
const MAX_RETRY_DELAY_MS = 30000;
|
|
18
|
+
const PERSIST_DELAY_MS = 1000;
|
|
19
|
+
/** How long `variant` waits for the running tests before answering null. */
|
|
20
|
+
exports.TESTS_WAIT_MS = 3000;
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
apiUrl: exports.DEFAULT_API_URL,
|
|
23
|
+
sessionTimeoutMinutes: 30,
|
|
24
|
+
flushIntervalSeconds: 10,
|
|
25
|
+
debug: false,
|
|
26
|
+
};
|
|
27
|
+
/** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
|
|
28
|
+
function generateId() {
|
|
29
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
30
|
+
const r = (Math.random() * 16) | 0;
|
|
31
|
+
return (c === 'x' ? r : (r & 3) | 8).toString(16);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
|
|
35
|
+
function unitHash(text) {
|
|
36
|
+
let h = 0x811c9dc5;
|
|
37
|
+
for (let i = 0; i < text.length; i++) {
|
|
38
|
+
h ^= text.charCodeAt(i);
|
|
39
|
+
h = Math.imul(h, 0x01000193);
|
|
40
|
+
}
|
|
41
|
+
return (h >>> 0) / 4294967296;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The version of `test` for `visitorId`, or null when they are outside its traffic
|
|
45
|
+
* share. Worked out from a hash, so the same user always gets the same version,
|
|
46
|
+
* exactly as the web tracker would give it.
|
|
47
|
+
*/
|
|
48
|
+
function assignVariant(visitorId, test) {
|
|
49
|
+
if (!test.variants.length)
|
|
50
|
+
return null;
|
|
51
|
+
if (unitHash(`${visitorId}:${test.id}:traffic`) >= test.traffic / 100)
|
|
52
|
+
return null;
|
|
53
|
+
const weights = test.variants.map((v) => Math.max(v.weight, 0));
|
|
54
|
+
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
55
|
+
const pick = unitHash(`${visitorId}:${test.id}:variant`);
|
|
56
|
+
let edge = 0;
|
|
57
|
+
for (let i = 0; i < test.variants.length; i++) {
|
|
58
|
+
edge += total > 0 ? weights[i] / total : 1 / test.variants.length;
|
|
59
|
+
if (pick < edge)
|
|
60
|
+
return test.variants[i].id;
|
|
61
|
+
}
|
|
62
|
+
return test.variants[test.variants.length - 1].id;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
|
|
66
|
+
* URLs, so the dashboard's pages, paths and funnels work for apps as they do for
|
|
67
|
+
* websites. Events are queued on the device and sent in batches; a batch the API
|
|
68
|
+
* has not acknowledged stays queued and is retried, including after a restart.
|
|
69
|
+
*/
|
|
70
|
+
class ThravicClient {
|
|
71
|
+
constructor(env) {
|
|
72
|
+
this.trackingId = '';
|
|
73
|
+
this.options = { ...DEFAULTS };
|
|
74
|
+
this.initialized = false;
|
|
75
|
+
this.ready = false;
|
|
76
|
+
/** Calls made before storage has loaded, replayed in order once it has. */
|
|
77
|
+
this.pending = [];
|
|
78
|
+
this.visitorId = '';
|
|
79
|
+
this.sessionId = '';
|
|
80
|
+
this.sessionStart = 0;
|
|
81
|
+
this.backgroundAt = null;
|
|
82
|
+
this.currentScreen = null;
|
|
83
|
+
this.optedOut = false;
|
|
84
|
+
this.queue = [];
|
|
85
|
+
this.sending = false;
|
|
86
|
+
this.retryAttempt = 0;
|
|
87
|
+
this.retryScheduled = false;
|
|
88
|
+
this.persistScheduled = false;
|
|
89
|
+
this.timer = null;
|
|
90
|
+
this.unsubscribeAppState = null;
|
|
91
|
+
/** This session's running tests, loaded the first time `variant` is asked. */
|
|
92
|
+
this.tests = null;
|
|
93
|
+
/** Tests whose `experiment_view` this session has already sent. */
|
|
94
|
+
this.exposed = new Set();
|
|
95
|
+
this.env = env;
|
|
96
|
+
}
|
|
97
|
+
/** Start tracking. Call once, when the app starts; later calls are ignored. */
|
|
98
|
+
async init(trackingId, options = {}) {
|
|
99
|
+
if (this.initialized)
|
|
100
|
+
return;
|
|
101
|
+
if (!trackingId) {
|
|
102
|
+
this.env.log('[Thravic] init needs your tracking ID, e.g. Thravic.init("TF-1A2B3C4D").');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.initialized = true;
|
|
106
|
+
this.trackingId = trackingId;
|
|
107
|
+
this.options = { ...DEFAULTS, ...stripUndefined(options) };
|
|
108
|
+
this.options.apiUrl = this.options.apiUrl.replace(/\/+$/, '');
|
|
109
|
+
const { storage } = this.env;
|
|
110
|
+
try {
|
|
111
|
+
this.optedOut = (await storage.getItem(exports.OPT_OUT_KEY)) === '1';
|
|
112
|
+
this.visitorId = (await storage.getItem(exports.VISITOR_KEY)) || '';
|
|
113
|
+
if (!this.visitorId) {
|
|
114
|
+
this.visitorId = generateId();
|
|
115
|
+
await storage.setItem(exports.VISITOR_KEY, this.visitorId);
|
|
116
|
+
}
|
|
117
|
+
this.queue = parseQueue(await storage.getItem(exports.QUEUE_KEY));
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
// Storage unavailable: track this run only.
|
|
121
|
+
this.visitorId = this.visitorId || generateId();
|
|
122
|
+
this.debug('storage unavailable', err);
|
|
123
|
+
}
|
|
124
|
+
this.startSession();
|
|
125
|
+
this.unsubscribeAppState = this.env.onAppStateChange((active) => active ? this.foreground() : this.background());
|
|
126
|
+
this.timer = this.env.setInterval(() => void this.flush(), this.options.flushIntervalSeconds * 1000);
|
|
127
|
+
this.ready = true;
|
|
128
|
+
const pending = this.pending;
|
|
129
|
+
this.pending = [];
|
|
130
|
+
pending.forEach((call) => call());
|
|
131
|
+
void this.flush();
|
|
132
|
+
}
|
|
133
|
+
/** Record a screen view. Automatic with `trackNavigation`; call it yourself otherwise. */
|
|
134
|
+
screen(name, params) {
|
|
135
|
+
this.whenReady(() => {
|
|
136
|
+
const screen = String(name || '').trim().replace(/^\/+/, '');
|
|
137
|
+
if (!screen)
|
|
138
|
+
return;
|
|
139
|
+
this.currentScreen = screen;
|
|
140
|
+
this.enqueue('pageview', params && Object.keys(params).length ? { params } : undefined);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Record a custom event, e.g. `track('purchase', { amount: 49 })`. */
|
|
144
|
+
track(event, properties) {
|
|
145
|
+
this.whenReady(() => {
|
|
146
|
+
if (!event)
|
|
147
|
+
return;
|
|
148
|
+
this.enqueue('custom', { ...properties, event });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Link this device to your own user id. Sent as the web tracker sends it. */
|
|
152
|
+
identify(userId, traits) {
|
|
153
|
+
this.whenReady(() => {
|
|
154
|
+
if (!userId)
|
|
155
|
+
return;
|
|
156
|
+
this.enqueue('custom', { ...traits, event: 'identify', userId: String(userId) });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The version of A/B test `key` to show this user: a version id from the
|
|
161
|
+
* dashboard, or null for the original (not in the test, the test is not
|
|
162
|
+
* running, tracking is off, or the tests could not be loaded in time).
|
|
163
|
+
* The first answer in a session records that the user saw the test.
|
|
164
|
+
*/
|
|
165
|
+
variant(key) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
if (!this.initialized) {
|
|
168
|
+
this.env.log('[Thravic] Call Thravic.init before asking for a variant.');
|
|
169
|
+
resolve(null);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.whenReady(() => {
|
|
173
|
+
if (this.optedOut) {
|
|
174
|
+
resolve(null);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
void this.loadTests().then((tests) => {
|
|
178
|
+
const test = tests.find((t) => t.key === key);
|
|
179
|
+
const id = test ? assignVariant(this.visitorId, test) : null;
|
|
180
|
+
if (test && id && !this.exposed.has(test.id)) {
|
|
181
|
+
this.exposed.add(test.id);
|
|
182
|
+
this.enqueue('custom', { event: 'experiment_view', experiment: test.id, variant: id });
|
|
183
|
+
}
|
|
184
|
+
resolve(id);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
|
|
190
|
+
optOut() {
|
|
191
|
+
this.optedOut = true;
|
|
192
|
+
this.queue = [];
|
|
193
|
+
void this.env.storage.setItem(exports.OPT_OUT_KEY, '1').catch(() => undefined);
|
|
194
|
+
void this.env.storage.removeItem(exports.QUEUE_KEY).catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
/** Resume tracking after `optOut`. */
|
|
197
|
+
optIn() {
|
|
198
|
+
this.optedOut = false;
|
|
199
|
+
void this.env.storage.removeItem(exports.OPT_OUT_KEY).catch(() => undefined);
|
|
200
|
+
}
|
|
201
|
+
isOptedOut() {
|
|
202
|
+
return this.optedOut;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Record a screen view whenever the focused route changes. Pass the ref from
|
|
206
|
+
* `createNavigationContainerRef()` / `useNavigationContainerRef()` (React
|
|
207
|
+
* Navigation or Expo Router). Returns a function that stops it.
|
|
208
|
+
*/
|
|
209
|
+
trackNavigation(ref) {
|
|
210
|
+
let last = null;
|
|
211
|
+
const onState = () => {
|
|
212
|
+
if (ref.isReady && !ref.isReady())
|
|
213
|
+
return;
|
|
214
|
+
const route = ref.getCurrentRoute();
|
|
215
|
+
if (!route || route.name === last)
|
|
216
|
+
return;
|
|
217
|
+
last = route.name;
|
|
218
|
+
this.screen(route.name);
|
|
219
|
+
};
|
|
220
|
+
const unsubscribe = ref.addListener('state', onState);
|
|
221
|
+
onState();
|
|
222
|
+
return unsubscribe;
|
|
223
|
+
}
|
|
224
|
+
/** Send everything queued now. Resolves when the attempt is done. */
|
|
225
|
+
async flush() {
|
|
226
|
+
if (!this.ready || this.sending || this.optedOut || this.queue.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
this.sending = true;
|
|
229
|
+
const batch = this.queue.slice(0, exports.MAX_BATCH);
|
|
230
|
+
const sent = new Set(batch.map((e) => e.eventId));
|
|
231
|
+
let more = false;
|
|
232
|
+
try {
|
|
233
|
+
const response = await this.env.fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/batch`, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: { 'Content-Type': 'application/json' },
|
|
236
|
+
body: JSON.stringify({ events: batch }),
|
|
237
|
+
});
|
|
238
|
+
// A 4xx other than 429 will never succeed: drop the batch rather than
|
|
239
|
+
// retrying it forever, as the web tracker does.
|
|
240
|
+
const rejected = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
241
|
+
if (!response.ok && !rejected)
|
|
242
|
+
throw new Error(`HTTP ${response.status}`);
|
|
243
|
+
if (rejected)
|
|
244
|
+
this.debug(`batch rejected with ${response.status}`);
|
|
245
|
+
this.queue = this.queue.filter((e) => !sent.has(e.eventId));
|
|
246
|
+
this.retryAttempt = 0;
|
|
247
|
+
this.persist();
|
|
248
|
+
more = this.queue.length > 0;
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
this.debug('send failed, will retry', err);
|
|
252
|
+
this.scheduleRetry();
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
this.sending = false;
|
|
256
|
+
}
|
|
257
|
+
if (more)
|
|
258
|
+
await this.flush();
|
|
259
|
+
}
|
|
260
|
+
/** Stop timers and listeners. For tests and hot reload; apps never need it. */
|
|
261
|
+
shutdown() {
|
|
262
|
+
if (this.timer !== null)
|
|
263
|
+
this.env.clearInterval(this.timer);
|
|
264
|
+
this.unsubscribeAppState?.();
|
|
265
|
+
this.timer = null;
|
|
266
|
+
this.unsubscribeAppState = null;
|
|
267
|
+
}
|
|
268
|
+
// ── Sessions ─────────────────────────────────────────────────────────────
|
|
269
|
+
startSession() {
|
|
270
|
+
this.sessionId = generateId();
|
|
271
|
+
this.sessionStart = this.env.now();
|
|
272
|
+
this.backgroundAt = null;
|
|
273
|
+
// A new session picks up tests started or ended since the last one.
|
|
274
|
+
this.tests = null;
|
|
275
|
+
this.exposed.clear();
|
|
276
|
+
this.enqueue('custom', { event: 'app_open' });
|
|
277
|
+
}
|
|
278
|
+
background() {
|
|
279
|
+
if (!this.ready || this.backgroundAt !== null)
|
|
280
|
+
return;
|
|
281
|
+
this.backgroundAt = this.env.now();
|
|
282
|
+
this.enqueue('session_end', {
|
|
283
|
+
duration: Math.round((this.backgroundAt - this.sessionStart) / 1000),
|
|
284
|
+
lastPage: this.screenUrl(),
|
|
285
|
+
});
|
|
286
|
+
void this.flush();
|
|
287
|
+
}
|
|
288
|
+
foreground() {
|
|
289
|
+
if (!this.ready || this.backgroundAt === null)
|
|
290
|
+
return;
|
|
291
|
+
const away = this.env.now() - this.backgroundAt;
|
|
292
|
+
this.backgroundAt = null;
|
|
293
|
+
if (away < this.options.sessionTimeoutMinutes * 60000)
|
|
294
|
+
return;
|
|
295
|
+
// Long enough away that this is a new visit; it starts on the screen in view.
|
|
296
|
+
this.startSession();
|
|
297
|
+
if (this.currentScreen)
|
|
298
|
+
this.enqueue('pageview');
|
|
299
|
+
}
|
|
300
|
+
// ── A/B tests ────────────────────────────────────────────────────────────
|
|
301
|
+
/** The running tests, or none when they fail to load or take too long. */
|
|
302
|
+
loadTests() {
|
|
303
|
+
if (!this.tests) {
|
|
304
|
+
this.tests = new Promise((resolve) => {
|
|
305
|
+
let done = false;
|
|
306
|
+
const settle = (list) => {
|
|
307
|
+
if (done)
|
|
308
|
+
return;
|
|
309
|
+
done = true;
|
|
310
|
+
resolve(parseTests(list));
|
|
311
|
+
};
|
|
312
|
+
this.env.setTimeout(() => settle([]), exports.TESTS_WAIT_MS);
|
|
313
|
+
this.env
|
|
314
|
+
.fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/config`)
|
|
315
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
316
|
+
.then((config) => settle(config?.experiments))
|
|
317
|
+
.catch((err) => {
|
|
318
|
+
this.debug('tests could not be loaded', err);
|
|
319
|
+
settle([]);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return this.tests;
|
|
324
|
+
}
|
|
325
|
+
// ── Queue ────────────────────────────────────────────────────────────────
|
|
326
|
+
whenReady(call) {
|
|
327
|
+
if (this.ready)
|
|
328
|
+
call();
|
|
329
|
+
else if (this.initialized)
|
|
330
|
+
this.pending.push(call);
|
|
331
|
+
else
|
|
332
|
+
this.env.log('[Thravic] Call Thravic.init before tracking.');
|
|
333
|
+
}
|
|
334
|
+
screenUrl() {
|
|
335
|
+
const host = this.options.bundleId || 'app';
|
|
336
|
+
return `app://${host}/${encodeURI(this.currentScreen || '')}`;
|
|
337
|
+
}
|
|
338
|
+
enqueue(type, data) {
|
|
339
|
+
if (this.optedOut)
|
|
340
|
+
return;
|
|
341
|
+
const device = this.env.device();
|
|
342
|
+
const event = {
|
|
343
|
+
eventId: generateId(),
|
|
344
|
+
type,
|
|
345
|
+
url: this.screenUrl(),
|
|
346
|
+
visitorId: this.visitorId,
|
|
347
|
+
sessionId: this.sessionId,
|
|
348
|
+
...stripUndefined({
|
|
349
|
+
os: device.os,
|
|
350
|
+
osVersion: device.osVersion,
|
|
351
|
+
deviceModel: device.deviceModel,
|
|
352
|
+
appVersion: this.options.appVersion,
|
|
353
|
+
screenWidth: device.screenWidth,
|
|
354
|
+
screenHeight: device.screenHeight,
|
|
355
|
+
language: device.language,
|
|
356
|
+
data,
|
|
357
|
+
}),
|
|
358
|
+
};
|
|
359
|
+
this.queue.push(event);
|
|
360
|
+
if (this.queue.length > exports.MAX_QUEUE)
|
|
361
|
+
this.queue = this.queue.slice(-exports.MAX_QUEUE);
|
|
362
|
+
this.debug(type, event.url, data ?? '');
|
|
363
|
+
this.persist();
|
|
364
|
+
if (this.queue.length >= FLUSH_AT)
|
|
365
|
+
void this.flush();
|
|
366
|
+
}
|
|
367
|
+
/** Write the queue to storage, at most once a second. */
|
|
368
|
+
persist() {
|
|
369
|
+
if (this.persistScheduled)
|
|
370
|
+
return;
|
|
371
|
+
this.persistScheduled = true;
|
|
372
|
+
this.env.setTimeout(() => {
|
|
373
|
+
this.persistScheduled = false;
|
|
374
|
+
const write = this.queue.length === 0
|
|
375
|
+
? this.env.storage.removeItem(exports.QUEUE_KEY)
|
|
376
|
+
: this.env.storage.setItem(exports.QUEUE_KEY, JSON.stringify(this.queue));
|
|
377
|
+
void write.catch(() => undefined);
|
|
378
|
+
}, PERSIST_DELAY_MS);
|
|
379
|
+
}
|
|
380
|
+
scheduleRetry() {
|
|
381
|
+
if (this.retryScheduled)
|
|
382
|
+
return;
|
|
383
|
+
this.retryScheduled = true;
|
|
384
|
+
const delay = Math.min(1000 * 2 ** this.retryAttempt, MAX_RETRY_DELAY_MS);
|
|
385
|
+
this.retryAttempt += 1;
|
|
386
|
+
this.env.setTimeout(() => {
|
|
387
|
+
this.retryScheduled = false;
|
|
388
|
+
void this.flush();
|
|
389
|
+
}, delay);
|
|
390
|
+
}
|
|
391
|
+
debug(...args) {
|
|
392
|
+
if (this.options.debug)
|
|
393
|
+
this.env.log('[Thravic]', ...args);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
exports.ThravicClient = ThravicClient;
|
|
397
|
+
function parseQueue(raw) {
|
|
398
|
+
if (!raw)
|
|
399
|
+
return [];
|
|
400
|
+
try {
|
|
401
|
+
const parsed = JSON.parse(raw);
|
|
402
|
+
if (!Array.isArray(parsed))
|
|
403
|
+
return [];
|
|
404
|
+
return parsed
|
|
405
|
+
.filter((e) => e && typeof e === 'object' && typeof e.eventId === 'string')
|
|
406
|
+
.slice(-exports.MAX_QUEUE);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function parseTests(list) {
|
|
413
|
+
if (!Array.isArray(list))
|
|
414
|
+
return [];
|
|
415
|
+
return list.filter((t) => !!t &&
|
|
416
|
+
typeof t.id === 'string' &&
|
|
417
|
+
typeof t.key === 'string' &&
|
|
418
|
+
typeof t.traffic === 'number' &&
|
|
419
|
+
Array.isArray(t.variants));
|
|
420
|
+
}
|
|
421
|
+
function stripUndefined(value) {
|
|
422
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
423
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ThravicClient } from './client';
|
|
2
|
+
export { ThravicClient, assignVariant } from './client';
|
|
3
|
+
export type { CollectEvent, DeviceInfo, Environment, Experiment, NavigationRefLike, Storage, ThravicOptions, } from './types';
|
|
4
|
+
/**
|
|
5
|
+
* Thravic for React Native.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { Thravic } from '@thravic/react-native';
|
|
9
|
+
*
|
|
10
|
+
* Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
|
|
11
|
+
* Thravic.trackNavigation(navigationRef); // screen views, automatically
|
|
12
|
+
* Thravic.track('purchase', { amount: 49 }); // your own events
|
|
13
|
+
* const version = await Thravic.variant('new-paywall'); // A/B tests
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare const Thravic: ThravicClient;
|
|
17
|
+
/**
|
|
18
|
+
* The version of A/B test `key` to show, for use in a component: `undefined`
|
|
19
|
+
* while it loads, then a version id or null for the original.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const version = useVariant('new-paywall');
|
|
23
|
+
* if (version === undefined) return null;
|
|
24
|
+
* return version === 'b' ? <NewPaywall /> : <Paywall />;
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function useVariant(key: string): string | null | undefined;
|
|
28
|
+
export default Thravic;
|