@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.
- package/README.md +221 -0
- package/dist/appstate.d.ts +1 -0
- package/dist/appstate.js +31 -0
- package/dist/buffer.d.ts +13 -0
- package/dist/buffer.js +27 -0
- package/dist/capture/forms.d.ts +16 -0
- package/dist/capture/forms.js +33 -0
- package/dist/capture/screen.d.ts +7 -0
- package/dist/capture/screen.js +30 -0
- package/dist/capture/tap.d.ts +11 -0
- package/dist/capture/tap.js +37 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +165 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +64 -0
- package/dist/deviceContext.d.ts +2 -0
- package/dist/deviceContext.js +43 -0
- package/dist/element.d.ts +3 -0
- package/dist/element.js +26 -0
- package/dist/hash.d.ts +1 -0
- package/dist/hash.js +6 -0
- package/dist/identity.d.ts +6 -0
- package/dist/identity.js +18 -0
- package/dist/ids.d.ts +1 -0
- package/dist/ids.js +13 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/interceptors/errors.d.ts +3 -0
- package/dist/interceptors/errors.js +59 -0
- package/dist/interceptors/network.d.ts +3 -0
- package/dist/interceptors/network.js +47 -0
- package/dist/nav/expoRouter.d.ts +1 -0
- package/dist/nav/expoRouter.js +17 -0
- package/dist/nav/reactNavigation.d.ts +6 -0
- package/dist/nav/reactNavigation.js +25 -0
- package/dist/react/ProbiePressable.d.ts +6 -0
- package/dist/react/ProbiePressable.js +21 -0
- package/dist/react/ProbieProvider.d.ts +7 -0
- package/dist/react/ProbieProvider.js +11 -0
- package/dist/react/hooks.d.ts +2 -0
- package/dist/react/hooks.js +4 -0
- package/dist/react/useProbieForm.d.ts +6 -0
- package/dist/react/useProbieForm.js +12 -0
- package/dist/session.d.ts +7 -0
- package/dist/session.js +36 -0
- package/dist/singleton.d.ts +10 -0
- package/dist/singleton.js +30 -0
- package/dist/transport.d.ts +9 -0
- package/dist/transport.js +67 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +1 -0
- package/dist/url.d.ts +1 -0
- package/dist/url.js +21 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- 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;
|
package/dist/appstate.js
ADDED
|
@@ -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
|
+
}
|
package/dist/buffer.d.ts
ADDED
|
@@ -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,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
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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;
|