@probie-dev/react-native 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.
Files changed (56) hide show
  1. package/README.md +221 -0
  2. package/dist/appstate.d.ts +1 -0
  3. package/dist/appstate.js +31 -0
  4. package/dist/buffer.d.ts +13 -0
  5. package/dist/buffer.js +27 -0
  6. package/dist/capture/forms.d.ts +16 -0
  7. package/dist/capture/forms.js +33 -0
  8. package/dist/capture/screen.d.ts +7 -0
  9. package/dist/capture/screen.js +30 -0
  10. package/dist/capture/tap.d.ts +11 -0
  11. package/dist/capture/tap.js +37 -0
  12. package/dist/client.d.ts +33 -0
  13. package/dist/client.js +165 -0
  14. package/dist/config.d.ts +18 -0
  15. package/dist/config.js +64 -0
  16. package/dist/deviceContext.d.ts +2 -0
  17. package/dist/deviceContext.js +43 -0
  18. package/dist/element.d.ts +3 -0
  19. package/dist/element.js +26 -0
  20. package/dist/hash.d.ts +1 -0
  21. package/dist/hash.js +6 -0
  22. package/dist/identity.d.ts +6 -0
  23. package/dist/identity.js +18 -0
  24. package/dist/ids.d.ts +1 -0
  25. package/dist/ids.js +13 -0
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +7 -0
  28. package/dist/interceptors/errors.d.ts +3 -0
  29. package/dist/interceptors/errors.js +59 -0
  30. package/dist/interceptors/network.d.ts +3 -0
  31. package/dist/interceptors/network.js +47 -0
  32. package/dist/nav/expoRouter.d.ts +1 -0
  33. package/dist/nav/expoRouter.js +17 -0
  34. package/dist/nav/reactNavigation.d.ts +6 -0
  35. package/dist/nav/reactNavigation.js +25 -0
  36. package/dist/react/ProbiePressable.d.ts +6 -0
  37. package/dist/react/ProbiePressable.js +21 -0
  38. package/dist/react/ProbieProvider.d.ts +7 -0
  39. package/dist/react/ProbieProvider.js +11 -0
  40. package/dist/react/hooks.d.ts +2 -0
  41. package/dist/react/hooks.js +4 -0
  42. package/dist/react/useProbieForm.d.ts +6 -0
  43. package/dist/react/useProbieForm.js +12 -0
  44. package/dist/session.d.ts +7 -0
  45. package/dist/session.js +36 -0
  46. package/dist/singleton.d.ts +10 -0
  47. package/dist/singleton.js +30 -0
  48. package/dist/transport.d.ts +9 -0
  49. package/dist/transport.js +67 -0
  50. package/dist/types.d.ts +66 -0
  51. package/dist/types.js +1 -0
  52. package/dist/url.d.ts +1 -0
  53. package/dist/url.js +21 -0
  54. package/dist/version.d.ts +2 -0
  55. package/dist/version.js +2 -0
  56. package/package.json +64 -0
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # @probie-dev/react-native
2
+
3
+ Passive behavioral telemetry for **Expo / React Native** apps. It's the mobile
4
+ counterpart to Probie's browser widget: it captures errors, network failures,
5
+ taps/rage-taps, form friction, and screen views, then batches them to Probie's
6
+ ingest endpoint.
7
+
8
+ The SDK never reads input values or raw text. Element labels are **hashed**, and
9
+ query strings are dropped by default.
10
+
11
+ - **Target:** Expo managed workflow, SDK 50+. Expo-Go-safe dependencies.
12
+ - **Zero-config** auto-capture (errors, network, app lifecycle) **plus** opt-in
13
+ instrumentation for taps, forms, and screens.
14
+ - **Never throws** into your app — telemetry is strictly best-effort.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npx expo install @probie-dev/react-native
20
+ ```
21
+
22
+ Peer/companion modules (installed automatically as dependencies, but let Expo
23
+ pin the versions): `expo-application`, `expo-device`, `expo-constants`,
24
+ `expo-crypto`, `@react-native-async-storage/async-storage`.
25
+
26
+ ## Quick start
27
+
28
+ Wrap your app once, near the root:
29
+
30
+ ```tsx
31
+ import { ProbieProvider } from '@probie-dev/react-native';
32
+
33
+ export default function RootLayout() {
34
+ return (
35
+ <ProbieProvider config={{ token: 'YOUR_WIDGET_TOKEN', apiBase: 'https://probie.dev' }}>
36
+ <App />
37
+ </ProbieProvider>
38
+ );
39
+ }
40
+ ```
41
+
42
+ That alone gives you JS errors, unhandled rejections, network failures, and app
43
+ lifecycle flushing. Add navigation + interaction capture below.
44
+
45
+ ### Screen views
46
+
47
+ **Expo Router:**
48
+
49
+ ```tsx
50
+ import { useProbieExpoRouter } from '@probie-dev/react-native';
51
+
52
+ function ScreenTracking() {
53
+ useProbieExpoRouter(); // auto pageview on every path change
54
+ return null;
55
+ }
56
+ // render <ScreenTracking /> inside <ProbieProvider>
57
+ ```
58
+
59
+ **React Navigation:**
60
+
61
+ ```tsx
62
+ import { probieNavigationContainerProps } from '@probie-dev/react-native';
63
+
64
+ <NavigationContainer {...probieNavigationContainerProps()}>
65
+ {/* ... */}
66
+ </NavigationContainer>
67
+ ```
68
+
69
+ **Manual:**
70
+
71
+ ```tsx
72
+ import { trackScreen } from '@probie-dev/react-native';
73
+ trackScreen('Checkout');
74
+ ```
75
+
76
+ > The Expo Router adapter captures the route **template** (`/reset/[token]`), not
77
+ > the concrete path (`/reset/abc123`), so dynamic segment values (tokens, invite
78
+ > codes, ids) are never emitted. With `trackScreen(name)` you control the string —
79
+ > pass a stable screen name, not a path containing raw ids.
80
+
81
+ ### Taps
82
+
83
+ Use `ProbiePressable` (a drop-in for `Pressable`) to capture taps and rage-taps.
84
+ It always calls your `onPress`:
85
+
86
+ ```tsx
87
+ import { ProbiePressable } from '@probie-dev/react-native';
88
+
89
+ <ProbiePressable testID="buy" accessibilityRole="button" accessibilityLabel="Buy now" onPress={buy}>
90
+ <Text>Buy now</Text>
91
+ </ProbiePressable>
92
+ ```
93
+
94
+ Or emit a tap explicitly: `track('click', {}, { testID: 'buy' })`.
95
+
96
+ ### Forms
97
+
98
+ `useProbieForm` is **telemetry-only** — it does not perform your submit. You keep
99
+ writing your own submit logic (a fetch, a mutation, navigation); you just also
100
+ call the two handlers so Probie can observe it.
101
+
102
+ ```tsx
103
+ import { useProbieForm } from '@probie-dev/react-native';
104
+
105
+ const form = useProbieForm('signup'); // stable key per logical form
106
+
107
+ <TextInput onFocus={form.onFocus} ... />
108
+ <Button
109
+ onPress={() => {
110
+ form.onSubmit(); // tell Probie a submit happened
111
+ mySubmitFunc(); // your actual work (fetch, etc.)
112
+ }}
113
+ title="Sign up"
114
+ />
115
+ ```
116
+
117
+ Or call Probie inside your own handler and pass it by reference:
118
+
119
+ ```tsx
120
+ async function mySubmitFunc() {
121
+ form.onSubmit();
122
+ await fetch('https://api.myapp.com/signup', { method: 'POST', /* ... */ });
123
+ }
124
+
125
+ <Button onPress={mySubmitFunc} title="Sign up" /> // note: no ()
126
+ ```
127
+
128
+ > ⚠️ `onPress` takes a **function to run on tap**, not the result of calling one.
129
+ > Don't write `onPress={form.onSubmit() && mySubmitFunc()}` — that runs both at
130
+ > render time (once, not on tap), and `&&` short-circuits on `onSubmit()`'s
131
+ > `undefined` return so `mySubmitFunc` never runs. Always wrap multiple calls in
132
+ > an arrow function: `onPress={() => { form.onSubmit(); mySubmitFunc(); }}`.
133
+
134
+ You don't need to report the request failure yourself: if the request in your
135
+ submit returns 4xx/5xx or throws, the network interceptor already emits a
136
+ `fetch_error`. Combined with `form_retry`, that gives Probie enough context to
137
+ surface frustrated sessions from these two handlers plus auto-capture.
138
+
139
+ Two submits within 10s → `form_retry`; touched-but-never-submitted → `form_abandon`
140
+ (emitted when the app backgrounds).
141
+
142
+ ### Identity
143
+
144
+ ```tsx
145
+ import { identify, reset } from '@probie-dev/react-native';
146
+ identify(user.id); // on login
147
+ reset(); // on logout (clears user, starts a new session)
148
+ ```
149
+
150
+ ## Configuration
151
+
152
+ | Option | Default | Notes |
153
+ |---|---|---|
154
+ | `token` | — (required) | Project widget token (same as the browser widget). |
155
+ | `apiBase` | — (required) | Probie ingest base URL, e.g. `https://probie.dev`. |
156
+ | `sampleRate` | `1` | 0..1. Sampled once per session; a sampled-out session captures nothing. |
157
+ | `captureQuery` | `false` | Include query strings on captured URLs (PII risk on deep links). |
158
+ | `captureErrors` | `true` | Install the global JS error handler. |
159
+ | `captureNetwork` | `true` | Install the XHR network-error interceptor. |
160
+ | `captureUnhandledRejections` | `true` | Track unhandled promise rejections. See load-order note below. |
161
+ | `idleTimeoutMs` | `null` | Resume the previous session if the app cold-starts within this window. Default: a new session per cold start. |
162
+ | `flushIntervalMs` | `12000` | Batch flush interval. |
163
+ | `appVersion` | auto | Override the version detected from `expo-application`. |
164
+
165
+ ## Events
166
+
167
+ Eight event types:
168
+ `pageview`, `click`, `rage_click`, `form_submit`, `form_abandon`, `form_retry`,
169
+ `fetch_error`, `js_error`.
170
+
171
+ Every event also carries a `context` object (platform, os_version, app_version,
172
+ build, device_model, sdk_name, sdk_version, anon_id).
173
+
174
+ ## Notes & gotchas
175
+
176
+ - **`accessibility`/`testID` drive element quality.** Set `testID` and
177
+ `accessibilityRole`/`accessibilityLabel` on interactive elements for useful
178
+ `element_path`s. Labels are hashed, never sent raw.
179
+ - **URLs are PII-scrubbed.** Captured URLs (including `fetch_error` urls) drop the
180
+ fragment, drop the query unless `captureQuery` is on, and always strip
181
+ `user:pass@` basic-auth credentials from the host.
182
+ - **Sentry / LogBox load order.** `captureUnhandledRejections` uses RN's promise
183
+ rejection-tracking, which can collide with Sentry RN. If you see duplicate
184
+ reports, set `captureUnhandledRejections: false`.
185
+ - **Network is XHR-only.** RN's `fetch` runs over `XMLHttpRequest`, so the SDK
186
+ patches XHR only (patching both would double-count). The SDK's own POST to the
187
+ ingest endpoint is excluded to avoid a feedback loop.
188
+ - **Hard kills can lose the last batch.** RN has no `sendBeacon`; the SDK flushes
189
+ on background/inactive and on an interval. This is best-effort, same as web.
190
+
191
+ ## Example App
192
+
193
+ `example/` is a minimal Expo Router app with a tracked tap, a rage target, a
194
+ failing fetch, a throw, a tracked form, and a **Send now** (flush) button.
195
+
196
+ 1. Set `example/app.json` → `extra.probieToken` and `extra.probieApiBase`.
197
+ 2. `cd example && npm install && npm start`, open in Expo Go, tap around, hit
198
+ **Send now**.
199
+ 3. Confirm events appear in Probie.
200
+
201
+ ## Development
202
+
203
+ ```bash
204
+ npm install
205
+ npm run typecheck
206
+ npm test # jest-expo
207
+ npm run build # emits dist/ (.js + .d.ts)
208
+ ```
209
+
210
+ ## Publishing
211
+
212
+ The package publishes to public npm under the `@probie-dev` scope. `files`
213
+ ships `dist/` only; `prepublishOnly` runs a clean build.
214
+
215
+ ```bash
216
+ npm run build
217
+ npm publish # publishConfig.access = public
218
+ ```
219
+
220
+ For a distribution smoke test, install the published package in a fresh Expo app
221
+ and confirm it builds in **EAS Build**, not just locally.
@@ -0,0 +1 @@
1
+ export declare function installAppState(onBackground: () => void): () => void;
@@ -0,0 +1,31 @@
1
+ export function installAppState(onBackground) {
2
+ let AppState;
3
+ try {
4
+ AppState = require('react-native').AppState;
5
+ }
6
+ catch {
7
+ return () => { };
8
+ }
9
+ if (!AppState || typeof AppState.addEventListener !== 'function')
10
+ return () => { };
11
+ const handler = (state) => {
12
+ if (state === 'inactive' || state === 'background') {
13
+ try {
14
+ onBackground();
15
+ }
16
+ catch {
17
+ }
18
+ }
19
+ };
20
+ const sub = AppState.addEventListener('change', handler);
21
+ return () => {
22
+ try {
23
+ if (sub && typeof sub.remove === 'function')
24
+ sub.remove();
25
+ else if (typeof AppState.removeEventListener === 'function')
26
+ AppState.removeEventListener('change', handler);
27
+ }
28
+ catch {
29
+ }
30
+ };
31
+ }
@@ -0,0 +1,13 @@
1
+ import type { ProbieEvent } from './types.js';
2
+ export declare const MAX_BUFFER = 50;
3
+ export declare const HARD_CAP = 200;
4
+ export declare class RingBuffer {
5
+ private readonly max;
6
+ private readonly hardCap;
7
+ private items;
8
+ constructor(max?: number, hardCap?: number);
9
+ push(e: ProbieEvent): boolean;
10
+ drain(): ProbieEvent[];
11
+ peek(): ProbieEvent[];
12
+ get size(): number;
13
+ }
package/dist/buffer.js ADDED
@@ -0,0 +1,27 @@
1
+ export const MAX_BUFFER = 50;
2
+ export const HARD_CAP = 200;
3
+ export class RingBuffer {
4
+ constructor(max = MAX_BUFFER, hardCap = HARD_CAP) {
5
+ this.max = max;
6
+ this.hardCap = hardCap;
7
+ this.items = [];
8
+ }
9
+ push(e) {
10
+ this.items.push(e);
11
+ if (this.items.length > this.hardCap) {
12
+ this.items.splice(0, this.items.length - this.hardCap);
13
+ }
14
+ return this.items.length >= this.max;
15
+ }
16
+ drain() {
17
+ const out = this.items;
18
+ this.items = [];
19
+ return out;
20
+ }
21
+ peek() {
22
+ return this.items;
23
+ }
24
+ get size() {
25
+ return this.items.length;
26
+ }
27
+ }
@@ -0,0 +1,16 @@
1
+ import type { ElementSource } from '../types.js';
2
+ export interface FormAbandon {
3
+ key: string;
4
+ source: ElementSource | null;
5
+ }
6
+ export declare class FormTracker {
7
+ private readonly retryWindowMs;
8
+ private submits;
9
+ private touched;
10
+ private submitted;
11
+ private sources;
12
+ constructor(retryWindowMs?: number);
13
+ touch(key: string, source?: ElementSource | null): void;
14
+ submit(key: string, source?: ElementSource | null): number;
15
+ abandons(): FormAbandon[];
16
+ }
@@ -0,0 +1,33 @@
1
+ export class FormTracker {
2
+ constructor(retryWindowMs = 10000) {
3
+ this.retryWindowMs = retryWindowMs;
4
+ this.submits = {};
5
+ this.touched = new Set();
6
+ this.submitted = new Set();
7
+ this.sources = {};
8
+ }
9
+ touch(key, source = null) {
10
+ this.touched.add(key);
11
+ if (source && !this.sources[key])
12
+ this.sources[key] = source;
13
+ }
14
+ submit(key, source = null) {
15
+ this.submitted.add(key);
16
+ if (source && !this.sources[key])
17
+ this.sources[key] = source;
18
+ const now = Date.now();
19
+ const recent = (this.submits[key] ?? []).filter((t) => now - t < this.retryWindowMs);
20
+ recent.push(now);
21
+ this.submits[key] = recent;
22
+ return recent.length >= 2 ? recent.length : 0;
23
+ }
24
+ abandons() {
25
+ const out = [];
26
+ for (const key of this.touched) {
27
+ if (!this.submitted.has(key))
28
+ out.push({ key, source: this.sources[key] ?? null });
29
+ }
30
+ this.touched.clear();
31
+ return out;
32
+ }
33
+ }
@@ -0,0 +1,7 @@
1
+ export declare class ScreenTracker {
2
+ url: string;
3
+ route: string;
4
+ referrer: string;
5
+ private lastRoute;
6
+ change(name: string, params: Record<string, unknown> | undefined, captureQuery: boolean): boolean;
7
+ }
@@ -0,0 +1,30 @@
1
+ function encodeParams(params) {
2
+ try {
3
+ return Object.keys(params)
4
+ .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`)
5
+ .join('&');
6
+ }
7
+ catch {
8
+ return '';
9
+ }
10
+ }
11
+ export class ScreenTracker {
12
+ constructor() {
13
+ this.url = '';
14
+ this.route = '';
15
+ this.referrer = '';
16
+ this.lastRoute = null;
17
+ }
18
+ change(name, params, captureQuery) {
19
+ const clean = String(name).replace(/^\/+/, '');
20
+ const route = '/' + clean;
21
+ if (this.lastRoute === route)
22
+ return false;
23
+ this.referrer = this.url;
24
+ this.route = route;
25
+ const query = captureQuery && params && Object.keys(params).length > 0 ? '?' + encodeParams(params) : '';
26
+ this.url = 'app://' + clean + query;
27
+ this.lastRoute = route;
28
+ return true;
29
+ }
30
+ }
@@ -0,0 +1,11 @@
1
+ export declare class RageTracker {
2
+ private readonly onRage;
3
+ private readonly windowMs;
4
+ private readonly threshold;
5
+ private lastKey;
6
+ private count;
7
+ private timer;
8
+ constructor(onRage: (count: number, key: string) => void, windowMs?: number, threshold?: number);
9
+ record(key: string): void;
10
+ dispose(): void;
11
+ }
@@ -0,0 +1,37 @@
1
+ export class RageTracker {
2
+ constructor(onRage, windowMs = 1000, threshold = 3) {
3
+ this.onRage = onRage;
4
+ this.windowMs = windowMs;
5
+ this.threshold = threshold;
6
+ this.lastKey = null;
7
+ this.count = 0;
8
+ this.timer = null;
9
+ }
10
+ record(key) {
11
+ if (key === this.lastKey) {
12
+ this.count++;
13
+ }
14
+ else {
15
+ this.lastKey = key;
16
+ this.count = 1;
17
+ }
18
+ if (this.timer)
19
+ clearTimeout(this.timer);
20
+ if (this.count >= this.threshold) {
21
+ const firedKey = this.lastKey ?? key;
22
+ this.onRage(this.count, firedKey);
23
+ this.count = 0;
24
+ this.lastKey = null;
25
+ }
26
+ this.timer = setTimeout(() => {
27
+ this.count = 0;
28
+ this.lastKey = null;
29
+ this.timer = null;
30
+ }, this.windowMs);
31
+ }
32
+ dispose() {
33
+ if (this.timer)
34
+ clearTimeout(this.timer);
35
+ this.timer = null;
36
+ }
37
+ }
@@ -0,0 +1,33 @@
1
+ import type { ElementInfo, ElementSource, EventType, ProbieClient, ProbieConfig } from './types.js';
2
+ import { type NormalizedConfig } from './config.js';
3
+ export declare class ProbieClientImpl implements ProbieClient {
4
+ readonly config: NormalizedConfig;
5
+ private readonly sampled;
6
+ private readonly buffer;
7
+ private readonly session;
8
+ private readonly identity;
9
+ private readonly context;
10
+ private readonly screen;
11
+ private readonly rage;
12
+ private readonly forms;
13
+ private userId;
14
+ private flushTimer;
15
+ private readonly uninstallers;
16
+ readonly ready: Promise<void>;
17
+ constructor(config: ProbieConfig);
18
+ private init;
19
+ private backfill;
20
+ private isOwnEndpoint;
21
+ private emit;
22
+ identify(userId: string | null): void;
23
+ reset(): void;
24
+ trackScreen(name: string, params?: Record<string, unknown>): void;
25
+ track(type: EventType, payload?: Record<string, unknown>, element?: ElementSource | ElementInfo | null): void;
26
+ recordTap(element: ElementSource | ElementInfo | null): void;
27
+ touchForm(key: string, source?: ElementSource | null): void;
28
+ submitForm(key: string, source?: ElementSource | null): void;
29
+ private emitAbandons;
30
+ private onBackground;
31
+ flush(): Promise<void>;
32
+ dispose(): void;
33
+ }
package/dist/client.js ADDED
@@ -0,0 +1,165 @@
1
+ import { normalizeConfig } from './config.js';
2
+ import { RingBuffer } from './buffer.js';
3
+ import { sendEvents } from './transport.js';
4
+ import { sanitizeUrl } from './url.js';
5
+ import { elementInfoFrom, elementKey } from './element.js';
6
+ import { buildDeviceContext } from './deviceContext.js';
7
+ import { createSession } from './session.js';
8
+ import { createIdentity } from './identity.js';
9
+ import { installErrors } from './interceptors/errors.js';
10
+ import { installNetwork } from './interceptors/network.js';
11
+ import { installAppState } from './appstate.js';
12
+ import { RageTracker } from './capture/tap.js';
13
+ import { FormTracker } from './capture/forms.js';
14
+ import { ScreenTracker } from './capture/screen.js';
15
+ export class ProbieClientImpl {
16
+ constructor(config) {
17
+ this.screen = new ScreenTracker();
18
+ this.userId = null;
19
+ this.flushTimer = null;
20
+ this.uninstallers = [];
21
+ this.config = normalizeConfig(config);
22
+ this.sampled = Math.random() < this.config.sampleRate;
23
+ this.buffer = new RingBuffer();
24
+ this.context = buildDeviceContext(this.config.appVersion);
25
+ this.session = createSession(this.config.storage, this.config.idleTimeoutMs);
26
+ this.identity = createIdentity(this.config.storage);
27
+ this.rage = new RageTracker((count, key) => this.emit('rage_click', { count }, { path: key }));
28
+ this.forms = new FormTracker();
29
+ this.ready = this.init();
30
+ }
31
+ async init() {
32
+ if (!this.sampled)
33
+ return;
34
+ if (this.config.captureErrors) {
35
+ this.uninstallers.push(installErrors((type, payload) => this.emit(type, payload), this.config.captureUnhandledRejections));
36
+ }
37
+ if (this.config.captureNetwork) {
38
+ this.uninstallers.push(installNetwork((type, payload) => this.emit(type, payload), (url) => this.isOwnEndpoint(url), (url) => sanitizeUrl(url, this.config.captureQuery)));
39
+ }
40
+ this.uninstallers.push(installAppState(() => void this.onBackground()));
41
+ this.flushTimer = setInterval(() => {
42
+ void this.flush();
43
+ }, this.config.flushIntervalMs);
44
+ await Promise.all([this.session.ready, this.identity.ready]);
45
+ this.backfill();
46
+ }
47
+ backfill() {
48
+ const sid = this.session.getId();
49
+ this.context.anon_id = this.identity.getAnonId();
50
+ for (const e of this.buffer.peek()) {
51
+ e.session_id = sid;
52
+ }
53
+ }
54
+ isOwnEndpoint(url) {
55
+ return typeof url === 'string' && url.indexOf(this.config.ownPrefix) === 0;
56
+ }
57
+ emit(type, payload = {}, element) {
58
+ try {
59
+ if (!this.sampled)
60
+ return;
61
+ const el = element === undefined ? null : elementInfoFrom(element);
62
+ const ev = {
63
+ type,
64
+ ts: Date.now(),
65
+ session_id: this.session.getId(),
66
+ user_id: this.userId,
67
+ url: this.screen.url,
68
+ route: this.screen.route,
69
+ referrer: this.screen.referrer,
70
+ element: el,
71
+ payload: payload || {},
72
+ context: this.context,
73
+ };
74
+ const shouldFlush = this.buffer.push(ev);
75
+ if (shouldFlush)
76
+ void this.flush();
77
+ }
78
+ catch {
79
+ }
80
+ }
81
+ identify(userId) {
82
+ this.userId = userId == null ? null : String(userId);
83
+ }
84
+ reset() {
85
+ this.userId = null;
86
+ this.session.rotate();
87
+ }
88
+ trackScreen(name, params) {
89
+ try {
90
+ if (this.screen.change(name, params, this.config.captureQuery)) {
91
+ this.emit('pageview', {});
92
+ }
93
+ }
94
+ catch {
95
+ }
96
+ }
97
+ track(type, payload, element) {
98
+ if (type === 'click') {
99
+ this.recordTap(element ?? null);
100
+ return;
101
+ }
102
+ this.emit(type, payload ?? {}, element);
103
+ }
104
+ recordTap(element) {
105
+ this.emit('click', {}, element);
106
+ try {
107
+ this.rage.record(elementKey(element));
108
+ }
109
+ catch {
110
+ }
111
+ }
112
+ touchForm(key, source = null) {
113
+ try {
114
+ this.forms.touch(key, source);
115
+ }
116
+ catch {
117
+ }
118
+ }
119
+ submitForm(key, source = null) {
120
+ try {
121
+ const el = source ?? { path: key };
122
+ this.emit('form_submit', {}, el);
123
+ const retry = this.forms.submit(key, source);
124
+ if (retry >= 2)
125
+ this.emit('form_retry', { count: retry }, el);
126
+ }
127
+ catch {
128
+ }
129
+ }
130
+ emitAbandons() {
131
+ for (const { key, source } of this.forms.abandons()) {
132
+ this.emit('form_abandon', {}, source ?? { path: key });
133
+ }
134
+ }
135
+ async onBackground() {
136
+ this.emitAbandons();
137
+ await this.flush();
138
+ }
139
+ async flush() {
140
+ try {
141
+ await this.ready;
142
+ const events = this.buffer.drain();
143
+ if (events.length === 0)
144
+ return;
145
+ await sendEvents(this.config.endpoint, this.config.token, events, this.config.fetchImpl);
146
+ }
147
+ catch {
148
+ }
149
+ }
150
+ dispose() {
151
+ if (this.flushTimer) {
152
+ clearInterval(this.flushTimer);
153
+ this.flushTimer = null;
154
+ }
155
+ for (const un of this.uninstallers) {
156
+ try {
157
+ un();
158
+ }
159
+ catch {
160
+ }
161
+ }
162
+ this.uninstallers.length = 0;
163
+ this.rage.dispose();
164
+ }
165
+ }
@@ -0,0 +1,18 @@
1
+ import type { AsyncStorageLike, ProbieConfig } from './types.js';
2
+ export interface NormalizedConfig {
3
+ token: string;
4
+ apiBase: string;
5
+ endpoint: string;
6
+ ownPrefix: string;
7
+ sampleRate: number;
8
+ captureQuery: boolean;
9
+ captureErrors: boolean;
10
+ captureNetwork: boolean;
11
+ captureUnhandledRejections: boolean;
12
+ idleTimeoutMs: number | null;
13
+ flushIntervalMs: number;
14
+ appVersion?: string;
15
+ storage: AsyncStorageLike;
16
+ fetchImpl: (url: string, init: unknown) => Promise<unknown>;
17
+ }
18
+ export declare function normalizeConfig(config: ProbieConfig): NormalizedConfig;