@thravic/react-native 0.1.0 → 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 +19 -0
- package/dist/client.d.ts +24 -1
- package/dist/client.js +106 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.js +30 -1
- package/dist/types.d.ts +12 -0
- package/package.json +4 -2
- package/src/client.ts +109 -0
- package/src/index.ts +30 -1
- package/src/types.ts +10 -0
package/README.md
CHANGED
|
@@ -83,6 +83,25 @@ Thravic.track('purchase', { amount: 49, currency: 'USD' });
|
|
|
83
83
|
Thravic.identify('user-42');
|
|
84
84
|
```
|
|
85
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
|
+
|
|
86
105
|
## Privacy
|
|
87
106
|
|
|
88
107
|
```ts
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Environment, NavigationRefLike, ThravicOptions } from './types';
|
|
1
|
+
import type { Environment, Experiment, NavigationRefLike, ThravicOptions } from './types';
|
|
2
2
|
export declare const VISITOR_KEY = "_tf_vid";
|
|
3
3
|
export declare const QUEUE_KEY = "_tf_queue";
|
|
4
4
|
export declare const OPT_OUT_KEY = "_tf_optout";
|
|
@@ -7,8 +7,18 @@ export declare const DEFAULT_API_URL = "https://api.thravic.com";
|
|
|
7
7
|
export declare const MAX_BATCH = 50;
|
|
8
8
|
/** Queued events kept while offline; the oldest go first. */
|
|
9
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;
|
|
10
12
|
/** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
|
|
11
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;
|
|
12
22
|
/**
|
|
13
23
|
* The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
|
|
14
24
|
* URLs, so the dashboard's pages, paths and funnels work for apps as they do for
|
|
@@ -36,6 +46,10 @@ export declare class ThravicClient {
|
|
|
36
46
|
private persistScheduled;
|
|
37
47
|
private timer;
|
|
38
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;
|
|
39
53
|
constructor(env: Environment);
|
|
40
54
|
/** Start tracking. Call once, when the app starts; later calls are ignored. */
|
|
41
55
|
init(trackingId: string, options?: ThravicOptions): Promise<void>;
|
|
@@ -45,6 +59,13 @@ export declare class ThravicClient {
|
|
|
45
59
|
track(event: string, properties?: Record<string, unknown>): void;
|
|
46
60
|
/** Link this device to your own user id. Sent as the web tracker sends it. */
|
|
47
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>;
|
|
48
69
|
/** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
|
|
49
70
|
optOut(): void;
|
|
50
71
|
/** Resume tracking after `optOut`. */
|
|
@@ -63,6 +84,8 @@ export declare class ThravicClient {
|
|
|
63
84
|
private startSession;
|
|
64
85
|
private background;
|
|
65
86
|
private foreground;
|
|
87
|
+
/** The running tests, or none when they fail to load or take too long. */
|
|
88
|
+
private loadTests;
|
|
66
89
|
private whenReady;
|
|
67
90
|
private screenUrl;
|
|
68
91
|
private enqueue;
|
package/dist/client.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ThravicClient = exports.MAX_QUEUE = exports.MAX_BATCH = exports.DEFAULT_API_URL = exports.OPT_OUT_KEY = exports.QUEUE_KEY = exports.VISITOR_KEY = void 0;
|
|
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
4
|
exports.generateId = generateId;
|
|
5
|
+
exports.unitHash = unitHash;
|
|
6
|
+
exports.assignVariant = assignVariant;
|
|
5
7
|
exports.VISITOR_KEY = '_tf_vid';
|
|
6
8
|
exports.QUEUE_KEY = '_tf_queue';
|
|
7
9
|
exports.OPT_OUT_KEY = '_tf_optout';
|
|
@@ -14,6 +16,8 @@ exports.MAX_QUEUE = 1000;
|
|
|
14
16
|
const FLUSH_AT = 10;
|
|
15
17
|
const MAX_RETRY_DELAY_MS = 30000;
|
|
16
18
|
const PERSIST_DELAY_MS = 1000;
|
|
19
|
+
/** How long `variant` waits for the running tests before answering null. */
|
|
20
|
+
exports.TESTS_WAIT_MS = 3000;
|
|
17
21
|
const DEFAULTS = {
|
|
18
22
|
apiUrl: exports.DEFAULT_API_URL,
|
|
19
23
|
sessionTimeoutMinutes: 30,
|
|
@@ -27,6 +31,36 @@ function generateId() {
|
|
|
27
31
|
return (c === 'x' ? r : (r & 3) | 8).toString(16);
|
|
28
32
|
});
|
|
29
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
|
+
}
|
|
30
64
|
/**
|
|
31
65
|
* The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
|
|
32
66
|
* URLs, so the dashboard's pages, paths and funnels work for apps as they do for
|
|
@@ -54,6 +88,10 @@ class ThravicClient {
|
|
|
54
88
|
this.persistScheduled = false;
|
|
55
89
|
this.timer = null;
|
|
56
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();
|
|
57
95
|
this.env = env;
|
|
58
96
|
}
|
|
59
97
|
/** Start tracking. Call once, when the app starts; later calls are ignored. */
|
|
@@ -118,6 +156,36 @@ class ThravicClient {
|
|
|
118
156
|
this.enqueue('custom', { ...traits, event: 'identify', userId: String(userId) });
|
|
119
157
|
});
|
|
120
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
|
+
}
|
|
121
189
|
/** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
|
|
122
190
|
optOut() {
|
|
123
191
|
this.optedOut = true;
|
|
@@ -202,6 +270,9 @@ class ThravicClient {
|
|
|
202
270
|
this.sessionId = generateId();
|
|
203
271
|
this.sessionStart = this.env.now();
|
|
204
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();
|
|
205
276
|
this.enqueue('custom', { event: 'app_open' });
|
|
206
277
|
}
|
|
207
278
|
background() {
|
|
@@ -226,6 +297,31 @@ class ThravicClient {
|
|
|
226
297
|
if (this.currentScreen)
|
|
227
298
|
this.enqueue('pageview');
|
|
228
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
|
+
}
|
|
229
325
|
// ── Queue ────────────────────────────────────────────────────────────────
|
|
230
326
|
whenReady(call) {
|
|
231
327
|
if (this.ready)
|
|
@@ -313,6 +409,15 @@ function parseQueue(raw) {
|
|
|
313
409
|
return [];
|
|
314
410
|
}
|
|
315
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
|
+
}
|
|
316
421
|
function stripUndefined(value) {
|
|
317
422
|
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
318
423
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ThravicClient } from './client';
|
|
2
|
-
export { ThravicClient } from './client';
|
|
3
|
-
export type { CollectEvent, DeviceInfo, Environment, NavigationRefLike, Storage, ThravicOptions, } from './types';
|
|
2
|
+
export { ThravicClient, assignVariant } from './client';
|
|
3
|
+
export type { CollectEvent, DeviceInfo, Environment, Experiment, NavigationRefLike, Storage, ThravicOptions, } from './types';
|
|
4
4
|
/**
|
|
5
5
|
* Thravic for React Native.
|
|
6
6
|
*
|
|
@@ -10,7 +10,19 @@ export type { CollectEvent, DeviceInfo, Environment, NavigationRefLike, Storage,
|
|
|
10
10
|
* Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
|
|
11
11
|
* Thravic.trackNavigation(navigationRef); // screen views, automatically
|
|
12
12
|
* Thravic.track('purchase', { amount: 49 }); // your own events
|
|
13
|
+
* const version = await Thravic.variant('new-paywall'); // A/B tests
|
|
13
14
|
* ```
|
|
14
15
|
*/
|
|
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;
|
|
16
28
|
export default Thravic;
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Thravic = exports.ThravicClient = void 0;
|
|
3
|
+
exports.Thravic = exports.assignVariant = exports.ThravicClient = void 0;
|
|
4
|
+
exports.useVariant = useVariant;
|
|
5
|
+
const react_1 = require("react");
|
|
4
6
|
const client_1 = require("./client");
|
|
5
7
|
const react_native_1 = require("./react-native");
|
|
6
8
|
var client_2 = require("./client");
|
|
7
9
|
Object.defineProperty(exports, "ThravicClient", { enumerable: true, get: function () { return client_2.ThravicClient; } });
|
|
10
|
+
Object.defineProperty(exports, "assignVariant", { enumerable: true, get: function () { return client_2.assignVariant; } });
|
|
8
11
|
/**
|
|
9
12
|
* Thravic for React Native.
|
|
10
13
|
*
|
|
@@ -14,7 +17,33 @@ Object.defineProperty(exports, "ThravicClient", { enumerable: true, get: functio
|
|
|
14
17
|
* Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
|
|
15
18
|
* Thravic.trackNavigation(navigationRef); // screen views, automatically
|
|
16
19
|
* Thravic.track('purchase', { amount: 49 }); // your own events
|
|
20
|
+
* const version = await Thravic.variant('new-paywall'); // A/B tests
|
|
17
21
|
* ```
|
|
18
22
|
*/
|
|
19
23
|
exports.Thravic = new client_1.ThravicClient(react_native_1.reactNativeEnvironment);
|
|
24
|
+
/**
|
|
25
|
+
* The version of A/B test `key` to show, for use in a component: `undefined`
|
|
26
|
+
* while it loads, then a version id or null for the original.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const version = useVariant('new-paywall');
|
|
30
|
+
* if (version === undefined) return null;
|
|
31
|
+
* return version === 'b' ? <NewPaywall /> : <Paywall />;
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
function useVariant(key) {
|
|
35
|
+
const [version, setVersion] = (0, react_1.useState)(undefined);
|
|
36
|
+
(0, react_1.useEffect)(() => {
|
|
37
|
+
let live = true;
|
|
38
|
+
setVersion(undefined);
|
|
39
|
+
void exports.Thravic.variant(key).then((id) => {
|
|
40
|
+
if (live)
|
|
41
|
+
setVersion(id);
|
|
42
|
+
});
|
|
43
|
+
return () => {
|
|
44
|
+
live = false;
|
|
45
|
+
};
|
|
46
|
+
}, [key]);
|
|
47
|
+
return version;
|
|
48
|
+
}
|
|
20
49
|
exports.default = exports.Thravic;
|
package/dist/types.d.ts
CHANGED
|
@@ -33,6 +33,18 @@ export interface CollectEvent {
|
|
|
33
33
|
utmCampaign?: string;
|
|
34
34
|
data?: Record<string, unknown>;
|
|
35
35
|
}
|
|
36
|
+
/** A running A/B test, as `GET /api/collect/{trackingId}/config` lists it. */
|
|
37
|
+
export interface Experiment {
|
|
38
|
+
id: string;
|
|
39
|
+
/** What the app asks for: `Thravic.variant(key)`. */
|
|
40
|
+
key: string;
|
|
41
|
+
/** Share of users in the test, 0–100. */
|
|
42
|
+
traffic: number;
|
|
43
|
+
variants: Array<{
|
|
44
|
+
id: string;
|
|
45
|
+
weight: number;
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
36
48
|
/** What the device reports about itself. */
|
|
37
49
|
export interface DeviceInfo {
|
|
38
50
|
os: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thravic/react-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Thravic analytics for React Native and Expo apps: screens, sessions and
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Thravic analytics for React Native and Expo apps: screens, sessions, custom events and A/B tests.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -29,10 +29,12 @@
|
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@react-native-async-storage/async-storage": ">=1.17.0",
|
|
32
|
+
"react": ">=17.0.0",
|
|
32
33
|
"react-native": ">=0.70.0"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
36
|
"@react-native-async-storage/async-storage": "^2.1.0",
|
|
37
|
+
"@types/react": "^18.3.31",
|
|
36
38
|
"react-native": "^0.76.0",
|
|
37
39
|
"typescript": "^5.6.0",
|
|
38
40
|
"vitest": "^4.0.18"
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CollectEvent,
|
|
3
3
|
Environment,
|
|
4
|
+
Experiment,
|
|
4
5
|
NavigationRefLike,
|
|
5
6
|
ThravicOptions,
|
|
6
7
|
} from './types';
|
|
@@ -18,6 +19,8 @@ export const MAX_QUEUE = 1000;
|
|
|
18
19
|
const FLUSH_AT = 10;
|
|
19
20
|
const MAX_RETRY_DELAY_MS = 30_000;
|
|
20
21
|
const PERSIST_DELAY_MS = 1_000;
|
|
22
|
+
/** How long `variant` waits for the running tests before answering null. */
|
|
23
|
+
export const TESTS_WAIT_MS = 3_000;
|
|
21
24
|
|
|
22
25
|
const DEFAULTS = {
|
|
23
26
|
apiUrl: DEFAULT_API_URL,
|
|
@@ -36,6 +39,35 @@ export function generateId(): string {
|
|
|
36
39
|
});
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
/** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
|
|
43
|
+
export function unitHash(text: string): number {
|
|
44
|
+
let h = 0x811c9dc5;
|
|
45
|
+
for (let i = 0; i < text.length; i++) {
|
|
46
|
+
h ^= text.charCodeAt(i);
|
|
47
|
+
h = Math.imul(h, 0x01000193);
|
|
48
|
+
}
|
|
49
|
+
return (h >>> 0) / 4294967296;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The version of `test` for `visitorId`, or null when they are outside its traffic
|
|
54
|
+
* share. Worked out from a hash, so the same user always gets the same version,
|
|
55
|
+
* exactly as the web tracker would give it.
|
|
56
|
+
*/
|
|
57
|
+
export function assignVariant(visitorId: string, test: Experiment): string | null {
|
|
58
|
+
if (!test.variants.length) return null;
|
|
59
|
+
if (unitHash(`${visitorId}:${test.id}:traffic`) >= test.traffic / 100) return null;
|
|
60
|
+
const weights = test.variants.map((v) => Math.max(v.weight, 0));
|
|
61
|
+
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
62
|
+
const pick = unitHash(`${visitorId}:${test.id}:variant`);
|
|
63
|
+
let edge = 0;
|
|
64
|
+
for (let i = 0; i < test.variants.length; i++) {
|
|
65
|
+
edge += total > 0 ? weights[i] / total : 1 / test.variants.length;
|
|
66
|
+
if (pick < edge) return test.variants[i].id;
|
|
67
|
+
}
|
|
68
|
+
return test.variants[test.variants.length - 1].id;
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
/**
|
|
40
72
|
* The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
|
|
41
73
|
* URLs, so the dashboard's pages, paths and funnels work for apps as they do for
|
|
@@ -66,6 +98,11 @@ export class ThravicClient {
|
|
|
66
98
|
private timer: unknown = null;
|
|
67
99
|
private unsubscribeAppState: (() => void) | null = null;
|
|
68
100
|
|
|
101
|
+
/** This session's running tests, loaded the first time `variant` is asked. */
|
|
102
|
+
private tests: Promise<Experiment[]> | null = null;
|
|
103
|
+
/** Tests whose `experiment_view` this session has already sent. */
|
|
104
|
+
private exposed = new Set<string>();
|
|
105
|
+
|
|
69
106
|
constructor(env: Environment) {
|
|
70
107
|
this.env = env;
|
|
71
108
|
}
|
|
@@ -139,6 +176,37 @@ export class ThravicClient {
|
|
|
139
176
|
});
|
|
140
177
|
}
|
|
141
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The version of A/B test `key` to show this user: a version id from the
|
|
181
|
+
* dashboard, or null for the original (not in the test, the test is not
|
|
182
|
+
* running, tracking is off, or the tests could not be loaded in time).
|
|
183
|
+
* The first answer in a session records that the user saw the test.
|
|
184
|
+
*/
|
|
185
|
+
variant(key: string): Promise<string | null> {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
if (!this.initialized) {
|
|
188
|
+
this.env.log('[Thravic] Call Thravic.init before asking for a variant.');
|
|
189
|
+
resolve(null);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.whenReady(() => {
|
|
193
|
+
if (this.optedOut) {
|
|
194
|
+
resolve(null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
void this.loadTests().then((tests) => {
|
|
198
|
+
const test = tests.find((t) => t.key === key);
|
|
199
|
+
const id = test ? assignVariant(this.visitorId, test) : null;
|
|
200
|
+
if (test && id && !this.exposed.has(test.id)) {
|
|
201
|
+
this.exposed.add(test.id);
|
|
202
|
+
this.enqueue('custom', { event: 'experiment_view', experiment: test.id, variant: id });
|
|
203
|
+
}
|
|
204
|
+
resolve(id);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
142
210
|
/** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
|
|
143
211
|
optOut(): void {
|
|
144
212
|
this.optedOut = true;
|
|
@@ -226,6 +294,9 @@ export class ThravicClient {
|
|
|
226
294
|
this.sessionId = generateId();
|
|
227
295
|
this.sessionStart = this.env.now();
|
|
228
296
|
this.backgroundAt = null;
|
|
297
|
+
// A new session picks up tests started or ended since the last one.
|
|
298
|
+
this.tests = null;
|
|
299
|
+
this.exposed.clear();
|
|
229
300
|
this.enqueue('custom', { event: 'app_open' });
|
|
230
301
|
}
|
|
231
302
|
|
|
@@ -250,6 +321,32 @@ export class ThravicClient {
|
|
|
250
321
|
if (this.currentScreen) this.enqueue('pageview');
|
|
251
322
|
}
|
|
252
323
|
|
|
324
|
+
// ── A/B tests ────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/** The running tests, or none when they fail to load or take too long. */
|
|
327
|
+
private loadTests(): Promise<Experiment[]> {
|
|
328
|
+
if (!this.tests) {
|
|
329
|
+
this.tests = new Promise((resolve) => {
|
|
330
|
+
let done = false;
|
|
331
|
+
const settle = (list: unknown) => {
|
|
332
|
+
if (done) return;
|
|
333
|
+
done = true;
|
|
334
|
+
resolve(parseTests(list));
|
|
335
|
+
};
|
|
336
|
+
this.env.setTimeout(() => settle([]), TESTS_WAIT_MS);
|
|
337
|
+
this.env
|
|
338
|
+
.fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/config`)
|
|
339
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
340
|
+
.then((config) => settle(config?.experiments))
|
|
341
|
+
.catch((err) => {
|
|
342
|
+
this.debug('tests could not be loaded', err);
|
|
343
|
+
settle([]);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
return this.tests;
|
|
348
|
+
}
|
|
349
|
+
|
|
253
350
|
// ── Queue ────────────────────────────────────────────────────────────────
|
|
254
351
|
|
|
255
352
|
private whenReady(call: () => void): void {
|
|
@@ -333,6 +430,18 @@ function parseQueue(raw: string | null): CollectEvent[] {
|
|
|
333
430
|
}
|
|
334
431
|
}
|
|
335
432
|
|
|
433
|
+
function parseTests(list: unknown): Experiment[] {
|
|
434
|
+
if (!Array.isArray(list)) return [];
|
|
435
|
+
return list.filter(
|
|
436
|
+
(t): t is Experiment =>
|
|
437
|
+
!!t &&
|
|
438
|
+
typeof t.id === 'string' &&
|
|
439
|
+
typeof t.key === 'string' &&
|
|
440
|
+
typeof t.traffic === 'number' &&
|
|
441
|
+
Array.isArray(t.variants),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
336
445
|
function stripUndefined<T extends object>(value: T): Partial<T> {
|
|
337
446
|
return Object.fromEntries(
|
|
338
447
|
Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''),
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
1
3
|
import { ThravicClient } from './client';
|
|
2
4
|
import { reactNativeEnvironment } from './react-native';
|
|
3
5
|
|
|
4
|
-
export { ThravicClient } from './client';
|
|
6
|
+
export { ThravicClient, assignVariant } from './client';
|
|
5
7
|
export type {
|
|
6
8
|
CollectEvent,
|
|
7
9
|
DeviceInfo,
|
|
8
10
|
Environment,
|
|
11
|
+
Experiment,
|
|
9
12
|
NavigationRefLike,
|
|
10
13
|
Storage,
|
|
11
14
|
ThravicOptions,
|
|
@@ -20,8 +23,34 @@ export type {
|
|
|
20
23
|
* Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
|
|
21
24
|
* Thravic.trackNavigation(navigationRef); // screen views, automatically
|
|
22
25
|
* Thravic.track('purchase', { amount: 49 }); // your own events
|
|
26
|
+
* const version = await Thravic.variant('new-paywall'); // A/B tests
|
|
23
27
|
* ```
|
|
24
28
|
*/
|
|
25
29
|
export const Thravic = new ThravicClient(reactNativeEnvironment);
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The version of A/B test `key` to show, for use in a component: `undefined`
|
|
33
|
+
* while it loads, then a version id or null for the original.
|
|
34
|
+
*
|
|
35
|
+
* ```tsx
|
|
36
|
+
* const version = useVariant('new-paywall');
|
|
37
|
+
* if (version === undefined) return null;
|
|
38
|
+
* return version === 'b' ? <NewPaywall /> : <Paywall />;
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function useVariant(key: string): string | null | undefined {
|
|
42
|
+
const [version, setVersion] = useState<string | null | undefined>(undefined);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let live = true;
|
|
45
|
+
setVersion(undefined);
|
|
46
|
+
void Thravic.variant(key).then((id) => {
|
|
47
|
+
if (live) setVersion(id);
|
|
48
|
+
});
|
|
49
|
+
return () => {
|
|
50
|
+
live = false;
|
|
51
|
+
};
|
|
52
|
+
}, [key]);
|
|
53
|
+
return version;
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
export default Thravic;
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,16 @@ export interface CollectEvent {
|
|
|
35
35
|
data?: Record<string, unknown>;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** A running A/B test, as `GET /api/collect/{trackingId}/config` lists it. */
|
|
39
|
+
export interface Experiment {
|
|
40
|
+
id: string;
|
|
41
|
+
/** What the app asks for: `Thravic.variant(key)`. */
|
|
42
|
+
key: string;
|
|
43
|
+
/** Share of users in the test, 0–100. */
|
|
44
|
+
traffic: number;
|
|
45
|
+
variants: Array<{ id: string; weight: number }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
/** What the device reports about itself. */
|
|
39
49
|
export interface DeviceInfo {
|
|
40
50
|
os: string;
|