@thravic/react-native 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # @thravic/react-native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ## Privacy
87
+
88
+ ```ts
89
+ Thravic.optOut(); // stops tracking and drops unsent events; remembered across launches
90
+ Thravic.optIn();
91
+ Thravic.isOptedOut();
92
+ ```
93
+
94
+ 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.
95
+
96
+ ## How it works
97
+
98
+ - 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.
99
+ - 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.
100
+ - 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.
101
+ - 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.
@@ -0,0 +1,73 @@
1
+ import type { Environment, 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
+ /** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
11
+ export declare function generateId(): string;
12
+ /**
13
+ * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
14
+ * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
15
+ * websites. Events are queued on the device and sent in batches; a batch the API
16
+ * has not acknowledged stays queued and is retried, including after a restart.
17
+ */
18
+ export declare class ThravicClient {
19
+ private env;
20
+ private trackingId;
21
+ private options;
22
+ private initialized;
23
+ private ready;
24
+ /** Calls made before storage has loaded, replayed in order once it has. */
25
+ private pending;
26
+ private visitorId;
27
+ private sessionId;
28
+ private sessionStart;
29
+ private backgroundAt;
30
+ private currentScreen;
31
+ private optedOut;
32
+ private queue;
33
+ private sending;
34
+ private retryAttempt;
35
+ private retryScheduled;
36
+ private persistScheduled;
37
+ private timer;
38
+ private unsubscribeAppState;
39
+ constructor(env: Environment);
40
+ /** Start tracking. Call once, when the app starts; later calls are ignored. */
41
+ init(trackingId: string, options?: ThravicOptions): Promise<void>;
42
+ /** Record a screen view. Automatic with `trackNavigation`; call it yourself otherwise. */
43
+ screen(name: string, params?: Record<string, unknown>): void;
44
+ /** Record a custom event, e.g. `track('purchase', { amount: 49 })`. */
45
+ track(event: string, properties?: Record<string, unknown>): void;
46
+ /** Link this device to your own user id. Sent as the web tracker sends it. */
47
+ identify(userId: string, traits?: Record<string, unknown>): void;
48
+ /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
49
+ optOut(): void;
50
+ /** Resume tracking after `optOut`. */
51
+ optIn(): void;
52
+ isOptedOut(): boolean;
53
+ /**
54
+ * Record a screen view whenever the focused route changes. Pass the ref from
55
+ * `createNavigationContainerRef()` / `useNavigationContainerRef()` (React
56
+ * Navigation or Expo Router). Returns a function that stops it.
57
+ */
58
+ trackNavigation(ref: NavigationRefLike): () => void;
59
+ /** Send everything queued now. Resolves when the attempt is done. */
60
+ flush(): Promise<void>;
61
+ /** Stop timers and listeners. For tests and hot reload; apps never need it. */
62
+ shutdown(): void;
63
+ private startSession;
64
+ private background;
65
+ private foreground;
66
+ private whenReady;
67
+ private screenUrl;
68
+ private enqueue;
69
+ /** Write the queue to storage, at most once a second. */
70
+ private persist;
71
+ private scheduleRetry;
72
+ private debug;
73
+ }
package/dist/client.js ADDED
@@ -0,0 +1,318 @@
1
+ "use strict";
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;
4
+ exports.generateId = generateId;
5
+ exports.VISITOR_KEY = '_tf_vid';
6
+ exports.QUEUE_KEY = '_tf_queue';
7
+ exports.OPT_OUT_KEY = '_tf_optout';
8
+ exports.DEFAULT_API_URL = 'https://api.thravic.com';
9
+ /** The collector takes at most 50 events per batch. */
10
+ exports.MAX_BATCH = 50;
11
+ /** Queued events kept while offline; the oldest go first. */
12
+ exports.MAX_QUEUE = 1000;
13
+ /** Send without waiting for the timer once this many are queued. */
14
+ const FLUSH_AT = 10;
15
+ const MAX_RETRY_DELAY_MS = 30000;
16
+ const PERSIST_DELAY_MS = 1000;
17
+ const DEFAULTS = {
18
+ apiUrl: exports.DEFAULT_API_URL,
19
+ sessionTimeoutMinutes: 30,
20
+ flushIntervalSeconds: 10,
21
+ debug: false,
22
+ };
23
+ /** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
24
+ function generateId() {
25
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
26
+ const r = (Math.random() * 16) | 0;
27
+ return (c === 'x' ? r : (r & 3) | 8).toString(16);
28
+ });
29
+ }
30
+ /**
31
+ * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
32
+ * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
33
+ * websites. Events are queued on the device and sent in batches; a batch the API
34
+ * has not acknowledged stays queued and is retried, including after a restart.
35
+ */
36
+ class ThravicClient {
37
+ constructor(env) {
38
+ this.trackingId = '';
39
+ this.options = { ...DEFAULTS };
40
+ this.initialized = false;
41
+ this.ready = false;
42
+ /** Calls made before storage has loaded, replayed in order once it has. */
43
+ this.pending = [];
44
+ this.visitorId = '';
45
+ this.sessionId = '';
46
+ this.sessionStart = 0;
47
+ this.backgroundAt = null;
48
+ this.currentScreen = null;
49
+ this.optedOut = false;
50
+ this.queue = [];
51
+ this.sending = false;
52
+ this.retryAttempt = 0;
53
+ this.retryScheduled = false;
54
+ this.persistScheduled = false;
55
+ this.timer = null;
56
+ this.unsubscribeAppState = null;
57
+ this.env = env;
58
+ }
59
+ /** Start tracking. Call once, when the app starts; later calls are ignored. */
60
+ async init(trackingId, options = {}) {
61
+ if (this.initialized)
62
+ return;
63
+ if (!trackingId) {
64
+ this.env.log('[Thravic] init needs your tracking ID, e.g. Thravic.init("TF-1A2B3C4D").');
65
+ return;
66
+ }
67
+ this.initialized = true;
68
+ this.trackingId = trackingId;
69
+ this.options = { ...DEFAULTS, ...stripUndefined(options) };
70
+ this.options.apiUrl = this.options.apiUrl.replace(/\/+$/, '');
71
+ const { storage } = this.env;
72
+ try {
73
+ this.optedOut = (await storage.getItem(exports.OPT_OUT_KEY)) === '1';
74
+ this.visitorId = (await storage.getItem(exports.VISITOR_KEY)) || '';
75
+ if (!this.visitorId) {
76
+ this.visitorId = generateId();
77
+ await storage.setItem(exports.VISITOR_KEY, this.visitorId);
78
+ }
79
+ this.queue = parseQueue(await storage.getItem(exports.QUEUE_KEY));
80
+ }
81
+ catch (err) {
82
+ // Storage unavailable: track this run only.
83
+ this.visitorId = this.visitorId || generateId();
84
+ this.debug('storage unavailable', err);
85
+ }
86
+ this.startSession();
87
+ this.unsubscribeAppState = this.env.onAppStateChange((active) => active ? this.foreground() : this.background());
88
+ this.timer = this.env.setInterval(() => void this.flush(), this.options.flushIntervalSeconds * 1000);
89
+ this.ready = true;
90
+ const pending = this.pending;
91
+ this.pending = [];
92
+ pending.forEach((call) => call());
93
+ void this.flush();
94
+ }
95
+ /** Record a screen view. Automatic with `trackNavigation`; call it yourself otherwise. */
96
+ screen(name, params) {
97
+ this.whenReady(() => {
98
+ const screen = String(name || '').trim().replace(/^\/+/, '');
99
+ if (!screen)
100
+ return;
101
+ this.currentScreen = screen;
102
+ this.enqueue('pageview', params && Object.keys(params).length ? { params } : undefined);
103
+ });
104
+ }
105
+ /** Record a custom event, e.g. `track('purchase', { amount: 49 })`. */
106
+ track(event, properties) {
107
+ this.whenReady(() => {
108
+ if (!event)
109
+ return;
110
+ this.enqueue('custom', { ...properties, event });
111
+ });
112
+ }
113
+ /** Link this device to your own user id. Sent as the web tracker sends it. */
114
+ identify(userId, traits) {
115
+ this.whenReady(() => {
116
+ if (!userId)
117
+ return;
118
+ this.enqueue('custom', { ...traits, event: 'identify', userId: String(userId) });
119
+ });
120
+ }
121
+ /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
122
+ optOut() {
123
+ this.optedOut = true;
124
+ this.queue = [];
125
+ void this.env.storage.setItem(exports.OPT_OUT_KEY, '1').catch(() => undefined);
126
+ void this.env.storage.removeItem(exports.QUEUE_KEY).catch(() => undefined);
127
+ }
128
+ /** Resume tracking after `optOut`. */
129
+ optIn() {
130
+ this.optedOut = false;
131
+ void this.env.storage.removeItem(exports.OPT_OUT_KEY).catch(() => undefined);
132
+ }
133
+ isOptedOut() {
134
+ return this.optedOut;
135
+ }
136
+ /**
137
+ * Record a screen view whenever the focused route changes. Pass the ref from
138
+ * `createNavigationContainerRef()` / `useNavigationContainerRef()` (React
139
+ * Navigation or Expo Router). Returns a function that stops it.
140
+ */
141
+ trackNavigation(ref) {
142
+ let last = null;
143
+ const onState = () => {
144
+ if (ref.isReady && !ref.isReady())
145
+ return;
146
+ const route = ref.getCurrentRoute();
147
+ if (!route || route.name === last)
148
+ return;
149
+ last = route.name;
150
+ this.screen(route.name);
151
+ };
152
+ const unsubscribe = ref.addListener('state', onState);
153
+ onState();
154
+ return unsubscribe;
155
+ }
156
+ /** Send everything queued now. Resolves when the attempt is done. */
157
+ async flush() {
158
+ if (!this.ready || this.sending || this.optedOut || this.queue.length === 0)
159
+ return;
160
+ this.sending = true;
161
+ const batch = this.queue.slice(0, exports.MAX_BATCH);
162
+ const sent = new Set(batch.map((e) => e.eventId));
163
+ let more = false;
164
+ try {
165
+ const response = await this.env.fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/batch`, {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({ events: batch }),
169
+ });
170
+ // A 4xx other than 429 will never succeed: drop the batch rather than
171
+ // retrying it forever, as the web tracker does.
172
+ const rejected = response.status >= 400 && response.status < 500 && response.status !== 429;
173
+ if (!response.ok && !rejected)
174
+ throw new Error(`HTTP ${response.status}`);
175
+ if (rejected)
176
+ this.debug(`batch rejected with ${response.status}`);
177
+ this.queue = this.queue.filter((e) => !sent.has(e.eventId));
178
+ this.retryAttempt = 0;
179
+ this.persist();
180
+ more = this.queue.length > 0;
181
+ }
182
+ catch (err) {
183
+ this.debug('send failed, will retry', err);
184
+ this.scheduleRetry();
185
+ }
186
+ finally {
187
+ this.sending = false;
188
+ }
189
+ if (more)
190
+ await this.flush();
191
+ }
192
+ /** Stop timers and listeners. For tests and hot reload; apps never need it. */
193
+ shutdown() {
194
+ if (this.timer !== null)
195
+ this.env.clearInterval(this.timer);
196
+ this.unsubscribeAppState?.();
197
+ this.timer = null;
198
+ this.unsubscribeAppState = null;
199
+ }
200
+ // ── Sessions ─────────────────────────────────────────────────────────────
201
+ startSession() {
202
+ this.sessionId = generateId();
203
+ this.sessionStart = this.env.now();
204
+ this.backgroundAt = null;
205
+ this.enqueue('custom', { event: 'app_open' });
206
+ }
207
+ background() {
208
+ if (!this.ready || this.backgroundAt !== null)
209
+ return;
210
+ this.backgroundAt = this.env.now();
211
+ this.enqueue('session_end', {
212
+ duration: Math.round((this.backgroundAt - this.sessionStart) / 1000),
213
+ lastPage: this.screenUrl(),
214
+ });
215
+ void this.flush();
216
+ }
217
+ foreground() {
218
+ if (!this.ready || this.backgroundAt === null)
219
+ return;
220
+ const away = this.env.now() - this.backgroundAt;
221
+ this.backgroundAt = null;
222
+ if (away < this.options.sessionTimeoutMinutes * 60000)
223
+ return;
224
+ // Long enough away that this is a new visit; it starts on the screen in view.
225
+ this.startSession();
226
+ if (this.currentScreen)
227
+ this.enqueue('pageview');
228
+ }
229
+ // ── Queue ────────────────────────────────────────────────────────────────
230
+ whenReady(call) {
231
+ if (this.ready)
232
+ call();
233
+ else if (this.initialized)
234
+ this.pending.push(call);
235
+ else
236
+ this.env.log('[Thravic] Call Thravic.init before tracking.');
237
+ }
238
+ screenUrl() {
239
+ const host = this.options.bundleId || 'app';
240
+ return `app://${host}/${encodeURI(this.currentScreen || '')}`;
241
+ }
242
+ enqueue(type, data) {
243
+ if (this.optedOut)
244
+ return;
245
+ const device = this.env.device();
246
+ const event = {
247
+ eventId: generateId(),
248
+ type,
249
+ url: this.screenUrl(),
250
+ visitorId: this.visitorId,
251
+ sessionId: this.sessionId,
252
+ ...stripUndefined({
253
+ os: device.os,
254
+ osVersion: device.osVersion,
255
+ deviceModel: device.deviceModel,
256
+ appVersion: this.options.appVersion,
257
+ screenWidth: device.screenWidth,
258
+ screenHeight: device.screenHeight,
259
+ language: device.language,
260
+ data,
261
+ }),
262
+ };
263
+ this.queue.push(event);
264
+ if (this.queue.length > exports.MAX_QUEUE)
265
+ this.queue = this.queue.slice(-exports.MAX_QUEUE);
266
+ this.debug(type, event.url, data ?? '');
267
+ this.persist();
268
+ if (this.queue.length >= FLUSH_AT)
269
+ void this.flush();
270
+ }
271
+ /** Write the queue to storage, at most once a second. */
272
+ persist() {
273
+ if (this.persistScheduled)
274
+ return;
275
+ this.persistScheduled = true;
276
+ this.env.setTimeout(() => {
277
+ this.persistScheduled = false;
278
+ const write = this.queue.length === 0
279
+ ? this.env.storage.removeItem(exports.QUEUE_KEY)
280
+ : this.env.storage.setItem(exports.QUEUE_KEY, JSON.stringify(this.queue));
281
+ void write.catch(() => undefined);
282
+ }, PERSIST_DELAY_MS);
283
+ }
284
+ scheduleRetry() {
285
+ if (this.retryScheduled)
286
+ return;
287
+ this.retryScheduled = true;
288
+ const delay = Math.min(1000 * 2 ** this.retryAttempt, MAX_RETRY_DELAY_MS);
289
+ this.retryAttempt += 1;
290
+ this.env.setTimeout(() => {
291
+ this.retryScheduled = false;
292
+ void this.flush();
293
+ }, delay);
294
+ }
295
+ debug(...args) {
296
+ if (this.options.debug)
297
+ this.env.log('[Thravic]', ...args);
298
+ }
299
+ }
300
+ exports.ThravicClient = ThravicClient;
301
+ function parseQueue(raw) {
302
+ if (!raw)
303
+ return [];
304
+ try {
305
+ const parsed = JSON.parse(raw);
306
+ if (!Array.isArray(parsed))
307
+ return [];
308
+ return parsed
309
+ .filter((e) => e && typeof e === 'object' && typeof e.eventId === 'string')
310
+ .slice(-exports.MAX_QUEUE);
311
+ }
312
+ catch {
313
+ return [];
314
+ }
315
+ }
316
+ function stripUndefined(value) {
317
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''));
318
+ }
@@ -0,0 +1,16 @@
1
+ import { ThravicClient } from './client';
2
+ export { ThravicClient } from './client';
3
+ export type { CollectEvent, DeviceInfo, Environment, 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
+ * ```
14
+ */
15
+ export declare const Thravic: ThravicClient;
16
+ export default Thravic;
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Thravic = exports.ThravicClient = void 0;
4
+ const client_1 = require("./client");
5
+ const react_native_1 = require("./react-native");
6
+ var client_2 = require("./client");
7
+ Object.defineProperty(exports, "ThravicClient", { enumerable: true, get: function () { return client_2.ThravicClient; } });
8
+ /**
9
+ * Thravic for React Native.
10
+ *
11
+ * ```ts
12
+ * import { Thravic } from '@thravic/react-native';
13
+ *
14
+ * Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
15
+ * Thravic.trackNavigation(navigationRef); // screen views, automatically
16
+ * Thravic.track('purchase', { amount: 49 }); // your own events
17
+ * ```
18
+ */
19
+ exports.Thravic = new client_1.ThravicClient(react_native_1.reactNativeEnvironment);
20
+ exports.default = exports.Thravic;
@@ -0,0 +1,2 @@
1
+ import type { Environment } from './types';
2
+ export declare const reactNativeEnvironment: Environment;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.reactNativeEnvironment = void 0;
7
+ const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
8
+ const react_native_1 = require("react-native");
9
+ function language() {
10
+ try {
11
+ return Intl.DateTimeFormat().resolvedOptions().locale;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ /** What React Native itself knows about the device, with no native module to install. */
18
+ function device() {
19
+ const constants = (react_native_1.Platform.constants || {});
20
+ const screen = react_native_1.Dimensions.get('screen');
21
+ const base = {
22
+ screenWidth: Math.round(screen.width),
23
+ screenHeight: Math.round(screen.height),
24
+ language: language(),
25
+ };
26
+ if (react_native_1.Platform.OS === 'android') {
27
+ return {
28
+ ...base,
29
+ os: 'Android',
30
+ // `Platform.Version` is the API level on Android; Release is "14".
31
+ osVersion: constants.Release || String(react_native_1.Platform.Version),
32
+ deviceModel: [constants.Manufacturer, constants.Model].filter(Boolean).join(' ') || undefined,
33
+ };
34
+ }
35
+ if (react_native_1.Platform.OS === 'ios') {
36
+ return {
37
+ ...base,
38
+ os: 'iOS',
39
+ osVersion: String(react_native_1.Platform.Version),
40
+ // iOS does not expose the model without a native module.
41
+ deviceModel: constants.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone',
42
+ };
43
+ }
44
+ return { ...base, os: react_native_1.Platform.OS };
45
+ }
46
+ exports.reactNativeEnvironment = {
47
+ storage: async_storage_1.default,
48
+ device,
49
+ fetch: (...args) => fetch(...args),
50
+ now: () => Date.now(),
51
+ onAppStateChange(listener) {
52
+ // 'inactive' is iOS passing through (Control Center, a call banner): not a
53
+ // departure. Only 'background' ends the visit and only 'active' resumes it.
54
+ const subscription = react_native_1.AppState.addEventListener('change', (state) => {
55
+ if (state === 'active')
56
+ listener(true);
57
+ else if (state === 'background')
58
+ listener(false);
59
+ });
60
+ return () => subscription.remove();
61
+ },
62
+ setInterval: (fn, ms) => setInterval(fn, ms),
63
+ clearInterval: (handle) => clearInterval(handle),
64
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
65
+ log: (...args) => console.warn(...args),
66
+ };
@@ -0,0 +1,74 @@
1
+ /** Options for `Thravic.init`. */
2
+ export interface ThravicOptions {
3
+ /** Where the Thravic API lives. The dashboard's install snippet fills this in. */
4
+ apiUrl?: string;
5
+ /** Your app's version, e.g. `Application.nativeApplicationVersion` (Expo) or
6
+ * `DeviceInfo.getVersion()` (react-native-device-info). Shown under Versions. */
7
+ appVersion?: string;
8
+ /** Your app's bundle ID, used as the host of screen URLs (`app://<bundleId>/Home`). */
9
+ bundleId?: string;
10
+ /** Minutes in the background after which the next open starts a new session. */
11
+ sessionTimeoutMinutes?: number;
12
+ /** Seconds between automatic sends of queued events. */
13
+ flushIntervalSeconds?: number;
14
+ /** Log what the SDK does to the console. */
15
+ debug?: boolean;
16
+ }
17
+ /** One event as `POST /api/collect/{trackingId}/batch` takes it. */
18
+ export interface CollectEvent {
19
+ eventId: string;
20
+ type: 'pageview' | 'custom' | 'session_end';
21
+ url: string;
22
+ visitorId: string;
23
+ sessionId: string;
24
+ screenWidth?: number;
25
+ screenHeight?: number;
26
+ language?: string;
27
+ os?: string;
28
+ osVersion?: string;
29
+ appVersion?: string;
30
+ deviceModel?: string;
31
+ utmSource?: string;
32
+ utmMedium?: string;
33
+ utmCampaign?: string;
34
+ data?: Record<string, unknown>;
35
+ }
36
+ /** What the device reports about itself. */
37
+ export interface DeviceInfo {
38
+ os: string;
39
+ osVersion?: string;
40
+ deviceModel?: string;
41
+ screenWidth?: number;
42
+ screenHeight?: number;
43
+ language?: string;
44
+ }
45
+ /** Key-value storage that survives app restarts (AsyncStorage's shape). */
46
+ export interface Storage {
47
+ getItem(key: string): Promise<string | null>;
48
+ setItem(key: string, value: string): Promise<void>;
49
+ removeItem(key: string): Promise<void>;
50
+ }
51
+ /** Everything the client needs from its platform, so it can run outside React Native. */
52
+ export interface Environment {
53
+ storage: Storage;
54
+ device: () => DeviceInfo;
55
+ fetch: typeof fetch;
56
+ now: () => number;
57
+ /** Calls `listener(true)` when the app comes to the foreground and `listener(false)`
58
+ * when it leaves; returns an unsubscribe. */
59
+ onAppStateChange: (listener: (active: boolean) => void) => () => void;
60
+ setInterval: (fn: () => void, ms: number) => unknown;
61
+ clearInterval: (handle: unknown) => void;
62
+ setTimeout: (fn: () => void, ms: number) => unknown;
63
+ log: (...args: unknown[]) => void;
64
+ }
65
+ /** The parts of a React Navigation container ref the screen tracker uses. Expo
66
+ * Router's `useNavigationContainerRef()` returns one too. */
67
+ export interface NavigationRefLike {
68
+ addListener(event: 'state', listener: () => void): () => void;
69
+ getCurrentRoute(): {
70
+ name: string;
71
+ params?: object;
72
+ } | undefined;
73
+ isReady?(): boolean;
74
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@thravic/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Thravic analytics for React Native and Expo apps: screens, sessions and custom events.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "react-native": "src/index.ts",
12
+ "files": [
13
+ "dist",
14
+ "src",
15
+ "!src/**/__tests__",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "analytics",
20
+ "react-native",
21
+ "expo",
22
+ "thravic"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
29
+ },
30
+ "peerDependencies": {
31
+ "@react-native-async-storage/async-storage": ">=1.17.0",
32
+ "react-native": ">=0.70.0"
33
+ },
34
+ "devDependencies": {
35
+ "@react-native-async-storage/async-storage": "^2.1.0",
36
+ "react-native": "^0.76.0",
37
+ "typescript": "^5.6.0",
38
+ "vitest": "^4.0.18"
39
+ }
40
+ }
package/src/client.ts ADDED
@@ -0,0 +1,340 @@
1
+ import type {
2
+ CollectEvent,
3
+ Environment,
4
+ NavigationRefLike,
5
+ ThravicOptions,
6
+ } from './types';
7
+
8
+ export const VISITOR_KEY = '_tf_vid';
9
+ export const QUEUE_KEY = '_tf_queue';
10
+ export const OPT_OUT_KEY = '_tf_optout';
11
+
12
+ export const DEFAULT_API_URL = 'https://api.thravic.com';
13
+ /** The collector takes at most 50 events per batch. */
14
+ export const MAX_BATCH = 50;
15
+ /** Queued events kept while offline; the oldest go first. */
16
+ export const MAX_QUEUE = 1000;
17
+ /** Send without waiting for the timer once this many are queued. */
18
+ const FLUSH_AT = 10;
19
+ const MAX_RETRY_DELAY_MS = 30_000;
20
+ const PERSIST_DELAY_MS = 1_000;
21
+
22
+ const DEFAULTS = {
23
+ apiUrl: DEFAULT_API_URL,
24
+ sessionTimeoutMinutes: 30,
25
+ flushIntervalSeconds: 10,
26
+ debug: false,
27
+ };
28
+
29
+ type Resolved = typeof DEFAULTS & ThravicOptions;
30
+
31
+ /** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
32
+ export function generateId(): string {
33
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
34
+ const r = (Math.random() * 16) | 0;
35
+ return (c === 'x' ? r : (r & 3) | 8).toString(16);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
41
+ * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
42
+ * websites. Events are queued on the device and sent in batches; a batch the API
43
+ * has not acknowledged stays queued and is retried, including after a restart.
44
+ */
45
+ export class ThravicClient {
46
+ private env: Environment;
47
+ private trackingId = '';
48
+ private options: Resolved = { ...DEFAULTS };
49
+ private initialized = false;
50
+ private ready = false;
51
+ /** Calls made before storage has loaded, replayed in order once it has. */
52
+ private pending: Array<() => void> = [];
53
+
54
+ private visitorId = '';
55
+ private sessionId = '';
56
+ private sessionStart = 0;
57
+ private backgroundAt: number | null = null;
58
+ private currentScreen: string | null = null;
59
+ private optedOut = false;
60
+
61
+ private queue: CollectEvent[] = [];
62
+ private sending = false;
63
+ private retryAttempt = 0;
64
+ private retryScheduled = false;
65
+ private persistScheduled = false;
66
+ private timer: unknown = null;
67
+ private unsubscribeAppState: (() => void) | null = null;
68
+
69
+ constructor(env: Environment) {
70
+ this.env = env;
71
+ }
72
+
73
+ /** Start tracking. Call once, when the app starts; later calls are ignored. */
74
+ async init(trackingId: string, options: ThravicOptions = {}): Promise<void> {
75
+ if (this.initialized) return;
76
+ if (!trackingId) {
77
+ this.env.log('[Thravic] init needs your tracking ID, e.g. Thravic.init("TF-1A2B3C4D").');
78
+ return;
79
+ }
80
+ this.initialized = true;
81
+ this.trackingId = trackingId;
82
+ this.options = { ...DEFAULTS, ...stripUndefined(options) };
83
+ this.options.apiUrl = this.options.apiUrl.replace(/\/+$/, '');
84
+
85
+ const { storage } = this.env;
86
+ try {
87
+ this.optedOut = (await storage.getItem(OPT_OUT_KEY)) === '1';
88
+ this.visitorId = (await storage.getItem(VISITOR_KEY)) || '';
89
+ if (!this.visitorId) {
90
+ this.visitorId = generateId();
91
+ await storage.setItem(VISITOR_KEY, this.visitorId);
92
+ }
93
+ this.queue = parseQueue(await storage.getItem(QUEUE_KEY));
94
+ } catch (err) {
95
+ // Storage unavailable: track this run only.
96
+ this.visitorId = this.visitorId || generateId();
97
+ this.debug('storage unavailable', err);
98
+ }
99
+
100
+ this.startSession();
101
+ this.unsubscribeAppState = this.env.onAppStateChange((active) =>
102
+ active ? this.foreground() : this.background(),
103
+ );
104
+ this.timer = this.env.setInterval(
105
+ () => void this.flush(),
106
+ this.options.flushIntervalSeconds * 1000,
107
+ );
108
+
109
+ this.ready = true;
110
+ const pending = this.pending;
111
+ this.pending = [];
112
+ pending.forEach((call) => call());
113
+ void this.flush();
114
+ }
115
+
116
+ /** Record a screen view. Automatic with `trackNavigation`; call it yourself otherwise. */
117
+ screen(name: string, params?: Record<string, unknown>): void {
118
+ this.whenReady(() => {
119
+ const screen = String(name || '').trim().replace(/^\/+/, '');
120
+ if (!screen) return;
121
+ this.currentScreen = screen;
122
+ this.enqueue('pageview', params && Object.keys(params).length ? { params } : undefined);
123
+ });
124
+ }
125
+
126
+ /** Record a custom event, e.g. `track('purchase', { amount: 49 })`. */
127
+ track(event: string, properties?: Record<string, unknown>): void {
128
+ this.whenReady(() => {
129
+ if (!event) return;
130
+ this.enqueue('custom', { ...properties, event });
131
+ });
132
+ }
133
+
134
+ /** Link this device to your own user id. Sent as the web tracker sends it. */
135
+ identify(userId: string, traits?: Record<string, unknown>): void {
136
+ this.whenReady(() => {
137
+ if (!userId) return;
138
+ this.enqueue('custom', { ...traits, event: 'identify', userId: String(userId) });
139
+ });
140
+ }
141
+
142
+ /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
143
+ optOut(): void {
144
+ this.optedOut = true;
145
+ this.queue = [];
146
+ void this.env.storage.setItem(OPT_OUT_KEY, '1').catch(() => undefined);
147
+ void this.env.storage.removeItem(QUEUE_KEY).catch(() => undefined);
148
+ }
149
+
150
+ /** Resume tracking after `optOut`. */
151
+ optIn(): void {
152
+ this.optedOut = false;
153
+ void this.env.storage.removeItem(OPT_OUT_KEY).catch(() => undefined);
154
+ }
155
+
156
+ isOptedOut(): boolean {
157
+ return this.optedOut;
158
+ }
159
+
160
+ /**
161
+ * Record a screen view whenever the focused route changes. Pass the ref from
162
+ * `createNavigationContainerRef()` / `useNavigationContainerRef()` (React
163
+ * Navigation or Expo Router). Returns a function that stops it.
164
+ */
165
+ trackNavigation(ref: NavigationRefLike): () => void {
166
+ let last: string | null = null;
167
+ const onState = () => {
168
+ if (ref.isReady && !ref.isReady()) return;
169
+ const route = ref.getCurrentRoute();
170
+ if (!route || route.name === last) return;
171
+ last = route.name;
172
+ this.screen(route.name);
173
+ };
174
+ const unsubscribe = ref.addListener('state', onState);
175
+ onState();
176
+ return unsubscribe;
177
+ }
178
+
179
+ /** Send everything queued now. Resolves when the attempt is done. */
180
+ async flush(): Promise<void> {
181
+ if (!this.ready || this.sending || this.optedOut || this.queue.length === 0) return;
182
+ this.sending = true;
183
+ const batch = this.queue.slice(0, MAX_BATCH);
184
+ const sent = new Set(batch.map((e) => e.eventId));
185
+ let more = false;
186
+ try {
187
+ const response = await this.env.fetch(
188
+ `${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/batch`,
189
+ {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify({ events: batch }),
193
+ },
194
+ );
195
+ // A 4xx other than 429 will never succeed: drop the batch rather than
196
+ // retrying it forever, as the web tracker does.
197
+ const rejected =
198
+ response.status >= 400 && response.status < 500 && response.status !== 429;
199
+ if (!response.ok && !rejected) throw new Error(`HTTP ${response.status}`);
200
+ if (rejected) this.debug(`batch rejected with ${response.status}`);
201
+
202
+ this.queue = this.queue.filter((e) => !sent.has(e.eventId));
203
+ this.retryAttempt = 0;
204
+ this.persist();
205
+ more = this.queue.length > 0;
206
+ } catch (err) {
207
+ this.debug('send failed, will retry', err);
208
+ this.scheduleRetry();
209
+ } finally {
210
+ this.sending = false;
211
+ }
212
+ if (more) await this.flush();
213
+ }
214
+
215
+ /** Stop timers and listeners. For tests and hot reload; apps never need it. */
216
+ shutdown(): void {
217
+ if (this.timer !== null) this.env.clearInterval(this.timer);
218
+ this.unsubscribeAppState?.();
219
+ this.timer = null;
220
+ this.unsubscribeAppState = null;
221
+ }
222
+
223
+ // ── Sessions ─────────────────────────────────────────────────────────────
224
+
225
+ private startSession(): void {
226
+ this.sessionId = generateId();
227
+ this.sessionStart = this.env.now();
228
+ this.backgroundAt = null;
229
+ this.enqueue('custom', { event: 'app_open' });
230
+ }
231
+
232
+ private background(): void {
233
+ if (!this.ready || this.backgroundAt !== null) return;
234
+ this.backgroundAt = this.env.now();
235
+ this.enqueue('session_end', {
236
+ duration: Math.round((this.backgroundAt - this.sessionStart) / 1000),
237
+ lastPage: this.screenUrl(),
238
+ });
239
+ void this.flush();
240
+ }
241
+
242
+ private foreground(): void {
243
+ if (!this.ready || this.backgroundAt === null) return;
244
+ const away = this.env.now() - this.backgroundAt;
245
+ this.backgroundAt = null;
246
+ if (away < this.options.sessionTimeoutMinutes * 60_000) return;
247
+
248
+ // Long enough away that this is a new visit; it starts on the screen in view.
249
+ this.startSession();
250
+ if (this.currentScreen) this.enqueue('pageview');
251
+ }
252
+
253
+ // ── Queue ────────────────────────────────────────────────────────────────
254
+
255
+ private whenReady(call: () => void): void {
256
+ if (this.ready) call();
257
+ else if (this.initialized) this.pending.push(call);
258
+ else this.env.log('[Thravic] Call Thravic.init before tracking.');
259
+ }
260
+
261
+ private screenUrl(): string {
262
+ const host = this.options.bundleId || 'app';
263
+ return `app://${host}/${encodeURI(this.currentScreen || '')}`;
264
+ }
265
+
266
+ private enqueue(type: CollectEvent['type'], data?: Record<string, unknown>): void {
267
+ if (this.optedOut) return;
268
+ const device = this.env.device();
269
+ const event: CollectEvent = {
270
+ eventId: generateId(),
271
+ type,
272
+ url: this.screenUrl(),
273
+ visitorId: this.visitorId,
274
+ sessionId: this.sessionId,
275
+ ...stripUndefined({
276
+ os: device.os,
277
+ osVersion: device.osVersion,
278
+ deviceModel: device.deviceModel,
279
+ appVersion: this.options.appVersion,
280
+ screenWidth: device.screenWidth,
281
+ screenHeight: device.screenHeight,
282
+ language: device.language,
283
+ data,
284
+ }),
285
+ };
286
+ this.queue.push(event);
287
+ if (this.queue.length > MAX_QUEUE) this.queue = this.queue.slice(-MAX_QUEUE);
288
+ this.debug(type, event.url, data ?? '');
289
+ this.persist();
290
+ if (this.queue.length >= FLUSH_AT) void this.flush();
291
+ }
292
+
293
+ /** Write the queue to storage, at most once a second. */
294
+ private persist(): void {
295
+ if (this.persistScheduled) return;
296
+ this.persistScheduled = true;
297
+ this.env.setTimeout(() => {
298
+ this.persistScheduled = false;
299
+ const write =
300
+ this.queue.length === 0
301
+ ? this.env.storage.removeItem(QUEUE_KEY)
302
+ : this.env.storage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
303
+ void write.catch(() => undefined);
304
+ }, PERSIST_DELAY_MS);
305
+ }
306
+
307
+ private scheduleRetry(): void {
308
+ if (this.retryScheduled) return;
309
+ this.retryScheduled = true;
310
+ const delay = Math.min(1000 * 2 ** this.retryAttempt, MAX_RETRY_DELAY_MS);
311
+ this.retryAttempt += 1;
312
+ this.env.setTimeout(() => {
313
+ this.retryScheduled = false;
314
+ void this.flush();
315
+ }, delay);
316
+ }
317
+
318
+ private debug(...args: unknown[]): void {
319
+ if (this.options.debug) this.env.log('[Thravic]', ...args);
320
+ }
321
+ }
322
+
323
+ function parseQueue(raw: string | null): CollectEvent[] {
324
+ if (!raw) return [];
325
+ try {
326
+ const parsed = JSON.parse(raw);
327
+ if (!Array.isArray(parsed)) return [];
328
+ return parsed
329
+ .filter((e) => e && typeof e === 'object' && typeof e.eventId === 'string')
330
+ .slice(-MAX_QUEUE);
331
+ } catch {
332
+ return [];
333
+ }
334
+ }
335
+
336
+ function stripUndefined<T extends object>(value: T): Partial<T> {
337
+ return Object.fromEntries(
338
+ Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''),
339
+ ) as Partial<T>;
340
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { ThravicClient } from './client';
2
+ import { reactNativeEnvironment } from './react-native';
3
+
4
+ export { ThravicClient } from './client';
5
+ export type {
6
+ CollectEvent,
7
+ DeviceInfo,
8
+ Environment,
9
+ NavigationRefLike,
10
+ Storage,
11
+ ThravicOptions,
12
+ } from './types';
13
+
14
+ /**
15
+ * Thravic for React Native.
16
+ *
17
+ * ```ts
18
+ * import { Thravic } from '@thravic/react-native';
19
+ *
20
+ * Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
21
+ * Thravic.trackNavigation(navigationRef); // screen views, automatically
22
+ * Thravic.track('purchase', { amount: 49 }); // your own events
23
+ * ```
24
+ */
25
+ export const Thravic = new ThravicClient(reactNativeEnvironment);
26
+
27
+ export default Thravic;
@@ -0,0 +1,71 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import { AppState, Dimensions, Platform } from 'react-native';
3
+
4
+ import type { DeviceInfo, Environment } from './types';
5
+
6
+ type Constants = {
7
+ Release?: string;
8
+ Model?: string;
9
+ Manufacturer?: string;
10
+ osVersion?: string;
11
+ interfaceIdiom?: string;
12
+ };
13
+
14
+ function language(): string | undefined {
15
+ try {
16
+ return Intl.DateTimeFormat().resolvedOptions().locale;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /** What React Native itself knows about the device, with no native module to install. */
23
+ function device(): DeviceInfo {
24
+ const constants = (Platform.constants || {}) as Constants;
25
+ const screen = Dimensions.get('screen');
26
+ const base = {
27
+ screenWidth: Math.round(screen.width),
28
+ screenHeight: Math.round(screen.height),
29
+ language: language(),
30
+ };
31
+
32
+ if (Platform.OS === 'android') {
33
+ return {
34
+ ...base,
35
+ os: 'Android',
36
+ // `Platform.Version` is the API level on Android; Release is "14".
37
+ osVersion: constants.Release || String(Platform.Version),
38
+ deviceModel: [constants.Manufacturer, constants.Model].filter(Boolean).join(' ') || undefined,
39
+ };
40
+ }
41
+ if (Platform.OS === 'ios') {
42
+ return {
43
+ ...base,
44
+ os: 'iOS',
45
+ osVersion: String(Platform.Version),
46
+ // iOS does not expose the model without a native module.
47
+ deviceModel: constants.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone',
48
+ };
49
+ }
50
+ return { ...base, os: Platform.OS };
51
+ }
52
+
53
+ export const reactNativeEnvironment: Environment = {
54
+ storage: AsyncStorage,
55
+ device,
56
+ fetch: (...args) => fetch(...args),
57
+ now: () => Date.now(),
58
+ onAppStateChange(listener) {
59
+ // 'inactive' is iOS passing through (Control Center, a call banner): not a
60
+ // departure. Only 'background' ends the visit and only 'active' resumes it.
61
+ const subscription = AppState.addEventListener('change', (state) => {
62
+ if (state === 'active') listener(true);
63
+ else if (state === 'background') listener(false);
64
+ });
65
+ return () => subscription.remove();
66
+ },
67
+ setInterval: (fn, ms) => setInterval(fn, ms),
68
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
69
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
70
+ log: (...args) => console.warn(...args),
71
+ };
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ /** Options for `Thravic.init`. */
2
+ export interface ThravicOptions {
3
+ /** Where the Thravic API lives. The dashboard's install snippet fills this in. */
4
+ apiUrl?: string;
5
+ /** Your app's version, e.g. `Application.nativeApplicationVersion` (Expo) or
6
+ * `DeviceInfo.getVersion()` (react-native-device-info). Shown under Versions. */
7
+ appVersion?: string;
8
+ /** Your app's bundle ID, used as the host of screen URLs (`app://<bundleId>/Home`). */
9
+ bundleId?: string;
10
+ /** Minutes in the background after which the next open starts a new session. */
11
+ sessionTimeoutMinutes?: number;
12
+ /** Seconds between automatic sends of queued events. */
13
+ flushIntervalSeconds?: number;
14
+ /** Log what the SDK does to the console. */
15
+ debug?: boolean;
16
+ }
17
+
18
+ /** One event as `POST /api/collect/{trackingId}/batch` takes it. */
19
+ export interface CollectEvent {
20
+ eventId: string;
21
+ type: 'pageview' | 'custom' | 'session_end';
22
+ url: string;
23
+ visitorId: string;
24
+ sessionId: string;
25
+ screenWidth?: number;
26
+ screenHeight?: number;
27
+ language?: string;
28
+ os?: string;
29
+ osVersion?: string;
30
+ appVersion?: string;
31
+ deviceModel?: string;
32
+ utmSource?: string;
33
+ utmMedium?: string;
34
+ utmCampaign?: string;
35
+ data?: Record<string, unknown>;
36
+ }
37
+
38
+ /** What the device reports about itself. */
39
+ export interface DeviceInfo {
40
+ os: string;
41
+ osVersion?: string;
42
+ deviceModel?: string;
43
+ screenWidth?: number;
44
+ screenHeight?: number;
45
+ language?: string;
46
+ }
47
+
48
+ /** Key-value storage that survives app restarts (AsyncStorage's shape). */
49
+ export interface Storage {
50
+ getItem(key: string): Promise<string | null>;
51
+ setItem(key: string, value: string): Promise<void>;
52
+ removeItem(key: string): Promise<void>;
53
+ }
54
+
55
+ /** Everything the client needs from its platform, so it can run outside React Native. */
56
+ export interface Environment {
57
+ storage: Storage;
58
+ device: () => DeviceInfo;
59
+ fetch: typeof fetch;
60
+ now: () => number;
61
+ /** Calls `listener(true)` when the app comes to the foreground and `listener(false)`
62
+ * when it leaves; returns an unsubscribe. */
63
+ onAppStateChange: (listener: (active: boolean) => void) => () => void;
64
+ setInterval: (fn: () => void, ms: number) => unknown;
65
+ clearInterval: (handle: unknown) => void;
66
+ setTimeout: (fn: () => void, ms: number) => unknown;
67
+ log: (...args: unknown[]) => void;
68
+ }
69
+
70
+ /** The parts of a React Navigation container ref the screen tracker uses. Expo
71
+ * Router's `useNavigationContainerRef()` returns one too. */
72
+ export interface NavigationRefLike {
73
+ addListener(event: 'state', listener: () => void): () => void;
74
+ getCurrentRoute(): { name: string; params?: object } | undefined;
75
+ isReady?(): boolean;
76
+ }