apparent-expo 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +105 -2
- package/dist/index.d.ts +181 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +995 -0
- package/dist/index.js.map +1 -0
- package/dist/navigation.d.ts +18 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +33 -0
- package/dist/navigation.js.map +1 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +24 -0
- package/dist/router.js.map +1 -0
- package/package.json +80 -4
- package/src/index.ts +970 -0
- package/src/navigation.ts +37 -0
- package/src/router.ts +25 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apparent SDK for Expo and bare React Native: product events for the Apparent dashboard. No native code.
|
|
3
|
+
* expo-application and expo-localization are used when the app has them; without them it reads React Native's own
|
|
4
|
+
* APIs (Settings / I18nManager, Intl) and react-native-device-info if present, or the app version passed to init().
|
|
5
|
+
*
|
|
6
|
+
* import { Apparent } from "apparent-expo";
|
|
7
|
+
* Apparent.init({ apiUrl: "https://…", app: "einb", key: "…" });
|
|
8
|
+
* Apparent.track("paywall_view", { source: "onboarding" });
|
|
9
|
+
*
|
|
10
|
+
* Built not to lose data:
|
|
11
|
+
* - every event is saved to the phone within a quarter second, and right away when the app goes to the background or crashes;
|
|
12
|
+
* - one batch is sent at a time, and a batch only leaves the saved queue once the server has it;
|
|
13
|
+
* - each request has a time limit, and retries wait longer each time (and honour Retry-After);
|
|
14
|
+
* - the batch carries the phone's clock, so the server can correct phones set to the wrong time; it ignores duplicates.
|
|
15
|
+
*
|
|
16
|
+
* Automatic events: first_open, app_open (each visit), app_updated, app_background (seconds in the app), error,
|
|
17
|
+
* deep_link_opened (the app opened by a link) and push_opened (a notification tapped, when the app has expo-notifications).
|
|
18
|
+
*/
|
|
19
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
20
|
+
import { AppState, I18nManager, Platform, Settings } from "react-native";
|
|
21
|
+
import * as ReactNative from "react-native";
|
|
22
|
+
export const SDK_VERSION = "0.2.0";
|
|
23
|
+
/** Printed once in development builds when the server answers 401. */
|
|
24
|
+
export const WRONG_KEY_WARNING = "Apparent: app id or key is wrong — events are not being saved";
|
|
25
|
+
const STORAGE_KEY = "apparent:v2";
|
|
26
|
+
const LEGACY_KEY = "apparent:v1";
|
|
27
|
+
const MAX_EVENT_AGE_MS = 7 * 24 * 3600000;
|
|
28
|
+
const MAX_ERRORS_PER_VISIT = 20;
|
|
29
|
+
const MAX_BATCH = 100;
|
|
30
|
+
const MAX_PROPS = 40;
|
|
31
|
+
const MAX_VALUE = 500;
|
|
32
|
+
const WRITE_WAIT_MS = 1000;
|
|
33
|
+
const uid = () => {
|
|
34
|
+
const hex = "0123456789abcdef";
|
|
35
|
+
let s = "";
|
|
36
|
+
for (let i = 0; i < 32; i++)
|
|
37
|
+
s += hex[Math.floor(Math.random() * 16)];
|
|
38
|
+
return s;
|
|
39
|
+
};
|
|
40
|
+
const cut = (s) => (s.length > MAX_VALUE ? s.slice(0, MAX_VALUE) : s);
|
|
41
|
+
/** One property value, flat. Never throws: a date is its ISO time, a function or symbol is left out (JSON has no text
|
|
42
|
+
* for them), anything else is its JSON text, or its String() when that fails, or null when both fail. */
|
|
43
|
+
function cleanValue(v) {
|
|
44
|
+
if (v === undefined || typeof v === "function" || typeof v === "symbol")
|
|
45
|
+
return undefined;
|
|
46
|
+
if (v === null || typeof v === "boolean")
|
|
47
|
+
return v;
|
|
48
|
+
if (typeof v === "number")
|
|
49
|
+
return Number.isFinite(v) ? v : null;
|
|
50
|
+
if (typeof v === "string")
|
|
51
|
+
return cut(v);
|
|
52
|
+
if (typeof v === "bigint")
|
|
53
|
+
return cut(String(v));
|
|
54
|
+
if (v instanceof Date)
|
|
55
|
+
return Number.isFinite(v.getTime()) ? v.toISOString() : null;
|
|
56
|
+
let s;
|
|
57
|
+
try {
|
|
58
|
+
s = JSON.stringify(v);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
s = undefined;
|
|
62
|
+
}
|
|
63
|
+
if (s === undefined) {
|
|
64
|
+
try {
|
|
65
|
+
s = String(v);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return cut(s);
|
|
72
|
+
}
|
|
73
|
+
/** An object's own [key, value] pairs, or none when it isn't a plain object or reading it throws (a Proxy, a getter). */
|
|
74
|
+
function entriesOf(input) {
|
|
75
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
76
|
+
return [];
|
|
77
|
+
try {
|
|
78
|
+
return Object.entries(input);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const readProps = (input) => Object.fromEntries(entriesOf(input));
|
|
85
|
+
function cleanProps(input) {
|
|
86
|
+
const entries = entriesOf(input);
|
|
87
|
+
if (!entries.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
const out = {};
|
|
90
|
+
let n = 0;
|
|
91
|
+
for (const [k, raw] of entries) {
|
|
92
|
+
const v = cleanValue(raw);
|
|
93
|
+
if (v === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
if (n >= MAX_PROPS)
|
|
96
|
+
break;
|
|
97
|
+
out[k.slice(0, 64)] = v;
|
|
98
|
+
n++;
|
|
99
|
+
}
|
|
100
|
+
return n ? out : undefined;
|
|
101
|
+
}
|
|
102
|
+
/** Query and fragment parameters whose values are never sent: anything that looks like a login or a secret. */
|
|
103
|
+
const SECRET_PARAM = /token|code|password|passwd|secret|key|auth|session|sig/i;
|
|
104
|
+
/**
|
|
105
|
+
* A link that opened the app, safe to store: secrets in the query or fragment become "[redacted]", a user:password@
|
|
106
|
+
* is dropped, and the whole string is cut to 500 characters. Also returns the utm_ campaign fields when present.
|
|
107
|
+
*/
|
|
108
|
+
export function cleanDeepLink(raw) {
|
|
109
|
+
let url = String(raw).replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i, "$1");
|
|
110
|
+
const utm = {};
|
|
111
|
+
const scrub = (part) => part.split("&").map((pair) => {
|
|
112
|
+
const eq = pair.indexOf("=");
|
|
113
|
+
if (eq < 0)
|
|
114
|
+
return pair;
|
|
115
|
+
const k = pair.slice(0, eq);
|
|
116
|
+
let name = k;
|
|
117
|
+
try {
|
|
118
|
+
name = decodeURIComponent(k);
|
|
119
|
+
}
|
|
120
|
+
catch { /* keep as is */ }
|
|
121
|
+
const v = pair.slice(eq + 1);
|
|
122
|
+
if (/^utm_(source|medium|campaign)$/i.test(name)) {
|
|
123
|
+
let value = v;
|
|
124
|
+
try {
|
|
125
|
+
value = decodeURIComponent(v.replace(/\+/g, " "));
|
|
126
|
+
}
|
|
127
|
+
catch { /* keep as is */ }
|
|
128
|
+
if (value)
|
|
129
|
+
utm[name.toLowerCase()] = value.slice(0, 100);
|
|
130
|
+
return pair;
|
|
131
|
+
}
|
|
132
|
+
return SECRET_PARAM.test(name) ? `${k}=[redacted]` : pair;
|
|
133
|
+
}).join("&");
|
|
134
|
+
const hash = url.indexOf("#");
|
|
135
|
+
let fragment = "";
|
|
136
|
+
if (hash >= 0) {
|
|
137
|
+
fragment = url.slice(hash + 1);
|
|
138
|
+
url = url.slice(0, hash);
|
|
139
|
+
}
|
|
140
|
+
const q = url.indexOf("?");
|
|
141
|
+
if (q >= 0)
|
|
142
|
+
url = `${url.slice(0, q)}?${scrub(url.slice(q + 1))}`;
|
|
143
|
+
if (hash >= 0)
|
|
144
|
+
url += `#${fragment.includes("=") ? scrub(fragment) : fragment}`;
|
|
145
|
+
return { url: url.slice(0, MAX_VALUE), ...utm };
|
|
146
|
+
}
|
|
147
|
+
/** Retry-After as seconds or an HTTP date, kept between 1 s and 1 h. */
|
|
148
|
+
function retryAfterMs(header) {
|
|
149
|
+
if (!header)
|
|
150
|
+
return undefined;
|
|
151
|
+
const secs = Number(header);
|
|
152
|
+
const ms = Number.isFinite(secs) ? secs * 1000 : Date.parse(header) - Date.now();
|
|
153
|
+
return Number.isFinite(ms) ? Math.min(3600000, Math.max(1000, ms)) : undefined;
|
|
154
|
+
}
|
|
155
|
+
const isText = (v, max = 128) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
156
|
+
/** Saved state as written by an older version, another tool, or a broken disk: keep what is valid, drop the rest. */
|
|
157
|
+
function validSaved(raw) {
|
|
158
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
159
|
+
return null;
|
|
160
|
+
const s = raw;
|
|
161
|
+
const anon = isText(s.anon) ? s.anon : undefined;
|
|
162
|
+
const queue = [];
|
|
163
|
+
for (const e of Array.isArray(s.queue) ? s.queue : []) {
|
|
164
|
+
if (!e || typeof e !== "object")
|
|
165
|
+
continue;
|
|
166
|
+
const q = e;
|
|
167
|
+
if (!isText(q.n, 80) || typeof q.t !== "number" || !Number.isFinite(q.t) || !isText(q.s, 64) || !isText(q.i, 80))
|
|
168
|
+
continue;
|
|
169
|
+
const ev = { n: q.n, t: q.t, s: q.s, i: q.i, a: isText(q.a) ? q.a : anon ?? "", u: isText(q.u) ? q.u : null };
|
|
170
|
+
const p = cleanProps(q.p);
|
|
171
|
+
if (p)
|
|
172
|
+
ev.p = p;
|
|
173
|
+
queue.push(ev);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
anon,
|
|
177
|
+
anonSource: s.anonSource === "provider" || s.anonSource === "reset" ? s.anonSource : "own",
|
|
178
|
+
userId: isText(s.userId) ? s.userId : null,
|
|
179
|
+
props: cleanProps(s.props) ?? {},
|
|
180
|
+
optedOut: s.optedOut === true,
|
|
181
|
+
// optIn() with optOutByDefault: dropping it here would forget someone's consent on every launch.
|
|
182
|
+
...(s.consent === true ? { consent: true } : {}),
|
|
183
|
+
app: isText(s.app) ? s.app : null,
|
|
184
|
+
queue,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function safe(fn) {
|
|
188
|
+
try {
|
|
189
|
+
return fn();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function safeAsync(fn) {
|
|
196
|
+
try {
|
|
197
|
+
return await fn();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function expoApplication() {
|
|
204
|
+
try {
|
|
205
|
+
return require("expo-application");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function expoLocalization() {
|
|
212
|
+
try {
|
|
213
|
+
return require("expo-localization");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function expoNotifications() {
|
|
220
|
+
try {
|
|
221
|
+
return require("expo-notifications");
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function deviceInfo() {
|
|
228
|
+
let mod;
|
|
229
|
+
try {
|
|
230
|
+
mod = require("react-native-device-info");
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
return mod ? (mod.default ?? mod) : null;
|
|
236
|
+
}
|
|
237
|
+
const text = (v) => (typeof v === "string" && v.length > 0 && v !== "unknown" ? v : null);
|
|
238
|
+
/** "en_DE@rg=atzzzz" / "en-DE" → { tag: "en-DE", region: "AT" } (the rg= override is the region the phone is set to). */
|
|
239
|
+
export function parseLocale(raw) {
|
|
240
|
+
if (typeof raw !== "string" || !raw)
|
|
241
|
+
return null;
|
|
242
|
+
const [base = "", ext = ""] = raw.split("@");
|
|
243
|
+
const tag = base.replace(/_/g, "-");
|
|
244
|
+
if (!tag)
|
|
245
|
+
return null;
|
|
246
|
+
const rg = /(?:^|;)rg=([a-z]{2})/i.exec(ext)?.[1];
|
|
247
|
+
const region = rg ?? tag.split("-").slice(1).find((p) => /^[A-Za-z]{2}$|^\d{3}$/.test(p));
|
|
248
|
+
return { tag, region: region ? region.toUpperCase() : null };
|
|
249
|
+
}
|
|
250
|
+
function intlOptions() {
|
|
251
|
+
return safe(() => Intl.DateTimeFormat().resolvedOptions()) ?? {};
|
|
252
|
+
}
|
|
253
|
+
/** Without expo-localization: iOS Settings (the phone's languages), Android I18nManager, else Intl. */
|
|
254
|
+
function rnLocale() {
|
|
255
|
+
let first;
|
|
256
|
+
let locale;
|
|
257
|
+
if (Platform.OS === "ios") {
|
|
258
|
+
const langs = safe(() => Settings.get("AppleLanguages"));
|
|
259
|
+
first = Array.isArray(langs) && typeof langs[0] === "string" ? langs[0] : undefined;
|
|
260
|
+
locale = safe(() => Settings.get("AppleLocale"));
|
|
261
|
+
}
|
|
262
|
+
else if (Platform.OS === "android") {
|
|
263
|
+
locale = safe(() => I18nManager.getConstants?.().localeIdentifier) ?? undefined;
|
|
264
|
+
}
|
|
265
|
+
const lang = parseLocale(first) ?? parseLocale(locale) ?? parseLocale(intlOptions().locale);
|
|
266
|
+
const region = parseLocale(locale)?.region ?? lang?.region ?? null;
|
|
267
|
+
return { languageTag: lang?.tag ?? null, regionCode: region };
|
|
268
|
+
}
|
|
269
|
+
/** The bundle's address: in the iOS Simulator it is a file in the Mac's CoreSimulator folder (release builds and
|
|
270
|
+
* expo-updates bundles alike), on a phone it is under /private/var or /var. In development it is Metro's address. */
|
|
271
|
+
function scriptUrl() {
|
|
272
|
+
const rn = ReactNative;
|
|
273
|
+
const mod = safe(() => rn.TurboModuleRegistry?.get?.("SourceCode")) ?? safe(() => rn.NativeModules?.SourceCode);
|
|
274
|
+
const url = safe(() => mod?.getConstants?.().scriptURL) ?? mod?.scriptURL;
|
|
275
|
+
return typeof url === "string" && url ? url : null;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* True in the iOS Simulator or an Android emulator, so Apparent leaves it out of the counts. expo-device's isDevice when
|
|
279
|
+
* the app has it; otherwise React Native's own: the bundle's path on iOS, the build fingerprint on Android.
|
|
280
|
+
*/
|
|
281
|
+
export function isSimulator() {
|
|
282
|
+
const expoDevice = safe(() => globalThis.expo?.modules?.ExpoDevice);
|
|
283
|
+
if (typeof expoDevice?.isDevice === "boolean")
|
|
284
|
+
return !expoDevice.isDevice;
|
|
285
|
+
const constants = (Platform.constants ?? {});
|
|
286
|
+
if (Platform.OS === "ios") {
|
|
287
|
+
const url = scriptUrl();
|
|
288
|
+
if (!url)
|
|
289
|
+
return false;
|
|
290
|
+
if (/\/CoreSimulator\//.test(url))
|
|
291
|
+
return true;
|
|
292
|
+
// Metro on this Mac: only the Simulator reaches it as localhost (a phone uses the Mac's address).
|
|
293
|
+
return /^https?:\/\/(localhost|127\.0\.0\.1)[:/]/i.test(url);
|
|
294
|
+
}
|
|
295
|
+
if (Platform.OS === "android") {
|
|
296
|
+
const fingerprint = typeof constants.Fingerprint === "string" ? constants.Fingerprint : "";
|
|
297
|
+
const model = typeof constants.Model === "string" ? constants.Model : "";
|
|
298
|
+
return /^generic|\/sdk_g?phone|\/emu64|emulator|genymotion|vbox86/i.test(fingerprint) || /Android SDK built for|Emulator/i.test(model);
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
function readDevice(cfg) {
|
|
303
|
+
const expoLoc = expoLocalization();
|
|
304
|
+
const locale = expoLoc ? safe(() => expoLoc.getLocales()[0]) : rnLocale();
|
|
305
|
+
const calendar = expoLoc ? safe(() => expoLoc.getCalendars?.()?.[0]) : { timeZone: text(intlOptions().timeZone) };
|
|
306
|
+
const expoApp = expoApplication();
|
|
307
|
+
const info = expoApp ? null : deviceInfo();
|
|
308
|
+
const app = text(cfg.appVersion) ?? (expoApp ? expoApp.nativeApplicationVersion ?? null : text(safe(() => info?.getVersion?.())));
|
|
309
|
+
const build = text(cfg.appBuild) ?? (expoApp ? expoApp.nativeBuildVersion ?? null : text(safe(() => info?.getBuildNumber?.())));
|
|
310
|
+
const constants = (Platform.constants ?? {});
|
|
311
|
+
// iOS has no Platform.constants.Model; expo-device's native module (when the app has it) gives "iPhone15,2" like Mixpanel's $model.
|
|
312
|
+
const expoDevice = safe(() => globalThis.expo?.modules?.ExpoDevice);
|
|
313
|
+
const model = [constants.Model, expoDevice?.modelId, expoDevice?.modelName].find((m) => typeof m === "string" && m.length > 0);
|
|
314
|
+
const device = {
|
|
315
|
+
platform: Platform.OS,
|
|
316
|
+
os: String(Platform.Version ?? ""),
|
|
317
|
+
app,
|
|
318
|
+
build,
|
|
319
|
+
model: model ?? null,
|
|
320
|
+
locale: locale?.languageTag ?? null,
|
|
321
|
+
region: locale?.regionCode ?? null,
|
|
322
|
+
tz: calendar?.timeZone ?? null,
|
|
323
|
+
};
|
|
324
|
+
// Only in a simulator: real phones send exactly what they always did.
|
|
325
|
+
if (safe(isSimulator))
|
|
326
|
+
device.simulator = true;
|
|
327
|
+
return device;
|
|
328
|
+
}
|
|
329
|
+
/** Android's Play install referrer, from expo-application or react-native-device-info. */
|
|
330
|
+
async function installReferrer() {
|
|
331
|
+
const expoApp = expoApplication();
|
|
332
|
+
if (expoApp?.getInstallReferrerAsync)
|
|
333
|
+
return (await safeAsync(() => expoApp.getInstallReferrerAsync())) ?? null;
|
|
334
|
+
const info = deviceInfo();
|
|
335
|
+
if (info?.getInstallReferrer)
|
|
336
|
+
return text(await safeAsync(() => info.getInstallReferrer()));
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
/** Event and screen names: a string that isn't blank. Anything else is ignored (SPEC 3.3). */
|
|
340
|
+
const isName = (v) => typeof v === "string" && v.trim().length > 0;
|
|
341
|
+
const isDev = () => typeof globalThis.__DEV__ === "boolean" && globalThis.__DEV__ === true;
|
|
342
|
+
export class ApparentClient {
|
|
343
|
+
constructor() {
|
|
344
|
+
this.cfg = null;
|
|
345
|
+
this.storage = AsyncStorage;
|
|
346
|
+
this.anon = "";
|
|
347
|
+
this.anonSource = "own";
|
|
348
|
+
this.userId = null;
|
|
349
|
+
this.props = {};
|
|
350
|
+
this.optedOut = false;
|
|
351
|
+
this.appVersion = null;
|
|
352
|
+
this.queue = [];
|
|
353
|
+
this.inflight = [];
|
|
354
|
+
this.sessionId = uid();
|
|
355
|
+
this.device = {};
|
|
356
|
+
this.loading = Promise.resolve();
|
|
357
|
+
/** The saved state has been read; until then nothing may be written over it. */
|
|
358
|
+
this.loaded = false;
|
|
359
|
+
this.sending = null;
|
|
360
|
+
this.sendAgain = false;
|
|
361
|
+
this.failures = 0;
|
|
362
|
+
this.retryAt = 0;
|
|
363
|
+
/** How long the current wait was meant to be, to spot a clock that jumped back since. */
|
|
364
|
+
this.retryWait = 0;
|
|
365
|
+
this.retryReason = null;
|
|
366
|
+
this.batchSize = MAX_BATCH;
|
|
367
|
+
this.persistTimer = null;
|
|
368
|
+
this.interval = null;
|
|
369
|
+
this.appStateSub = null;
|
|
370
|
+
this.appState = "active";
|
|
371
|
+
this.activeSince = null;
|
|
372
|
+
this.backgroundAt = null;
|
|
373
|
+
this.pendingOpen = false;
|
|
374
|
+
this.screenName = null;
|
|
375
|
+
this.errorsThisVisit = 0;
|
|
376
|
+
/** Events tracked before the saved state was read (see restore). */
|
|
377
|
+
this.early = new WeakMap();
|
|
378
|
+
this.resetBeforeLoad = false;
|
|
379
|
+
this.identifiedBeforeLoad = false;
|
|
380
|
+
this.warnedKey = false;
|
|
381
|
+
/** optIn() or optOut() before the saved state was read: that choice wins over the saved one. */
|
|
382
|
+
this.consentBeforeLoad = null;
|
|
383
|
+
this.consented = false;
|
|
384
|
+
this.warnedHook = false;
|
|
385
|
+
this.linkSub = null;
|
|
386
|
+
this.pushSub = null;
|
|
387
|
+
this.lastPush = null;
|
|
388
|
+
}
|
|
389
|
+
init(cfg) {
|
|
390
|
+
if (this.cfg)
|
|
391
|
+
return;
|
|
392
|
+
if (!cfg || typeof cfg !== "object" || !isText(cfg.apiUrl, 2048) || !isText(cfg.app) || !isText(cfg.key, 512)) {
|
|
393
|
+
// Not this one: a later init with apiUrl, app and key still works.
|
|
394
|
+
if (isDev() || cfg?.debug)
|
|
395
|
+
console.warn("Apparent: init needs apiUrl, app and key");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
this.start(cfg);
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
this.log("could not start", e);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
start(cfg) {
|
|
406
|
+
this.cfg = cfg;
|
|
407
|
+
if (cfg.storage)
|
|
408
|
+
this.storage = cfg.storage;
|
|
409
|
+
if (cfg.userId)
|
|
410
|
+
this.userId = String(cfg.userId).slice(0, 128);
|
|
411
|
+
this.device = readDevice(cfg);
|
|
412
|
+
const now = AppState.currentState;
|
|
413
|
+
// A launch by a silent push or background fetch is not a visit: wait until the app is really in front.
|
|
414
|
+
this.appState = now === "background" || now === "inactive" ? now : "active";
|
|
415
|
+
this.loading = this.restore(cfg).catch((e) => this.log("could not read saved events", e));
|
|
416
|
+
this.appStateSub = AppState.addEventListener("change", (s) => this.onAppState(s));
|
|
417
|
+
this.interval = setInterval(() => void this.flush(), cfg.flushIntervalMs ?? 10000);
|
|
418
|
+
if (cfg.captureErrors !== false && !cfg.disabled)
|
|
419
|
+
this.installErrorHandlers();
|
|
420
|
+
// Consent first: nothing is collected until the saved state says they agreed, or optIn() is called.
|
|
421
|
+
if (cfg.optOutByDefault)
|
|
422
|
+
this.optedOut = true;
|
|
423
|
+
if (!cfg.disabled && cfg.trackDeepLinks !== false)
|
|
424
|
+
this.watchDeepLinks();
|
|
425
|
+
if (!cfg.disabled && cfg.trackPushOpens !== false)
|
|
426
|
+
this.watchPushOpens();
|
|
427
|
+
}
|
|
428
|
+
/** deep_link_opened for the link the app was launched with, and for each link that brings it back to the front. */
|
|
429
|
+
watchDeepLinks() {
|
|
430
|
+
const linking = ReactNative.Linking;
|
|
431
|
+
if (!linking)
|
|
432
|
+
return;
|
|
433
|
+
const opened = (url) => { if (typeof url === "string" && url)
|
|
434
|
+
this.enqueue("deep_link_opened", cleanDeepLink(url)); };
|
|
435
|
+
safe(() => { void linking.getInitialURL?.().then(opened, () => { }); });
|
|
436
|
+
this.linkSub = safe(() => linking.addEventListener?.("url", (e) => opened(e?.url))) ?? null;
|
|
437
|
+
}
|
|
438
|
+
/** push_opened when a notification is tapped, with only the campaign and title the app put in its data. */
|
|
439
|
+
watchPushOpens() {
|
|
440
|
+
const n = expoNotifications();
|
|
441
|
+
if (!n)
|
|
442
|
+
return;
|
|
443
|
+
const tapped = (r) => {
|
|
444
|
+
const req = r?.notification?.request;
|
|
445
|
+
if (!req)
|
|
446
|
+
return;
|
|
447
|
+
const id = req.identifier ?? null;
|
|
448
|
+
if (id && id === this.lastPush)
|
|
449
|
+
return; // the launch response and the listener can both report one tap
|
|
450
|
+
this.lastPush = id;
|
|
451
|
+
const data = req.content?.data ?? {};
|
|
452
|
+
const pick = (v) => (typeof v === "string" && v ? v : undefined);
|
|
453
|
+
this.enqueue("push_opened", { campaign: pick(data.campaign) ?? pick(data.utm_campaign), title: pick(data.title) });
|
|
454
|
+
};
|
|
455
|
+
safe(() => { void n.getLastNotificationResponseAsync?.().then(tapped, () => { }); });
|
|
456
|
+
this.pushSub = safe(() => n.addNotificationResponseReceivedListener?.(tapped)) ?? null;
|
|
457
|
+
}
|
|
458
|
+
async restore(cfg) {
|
|
459
|
+
let saved = null;
|
|
460
|
+
try {
|
|
461
|
+
const raw = await this.storage.getItem(STORAGE_KEY);
|
|
462
|
+
if (raw)
|
|
463
|
+
saved = validSaved(JSON.parse(raw));
|
|
464
|
+
}
|
|
465
|
+
catch { /* unreadable: start fresh */ }
|
|
466
|
+
if (!saved) {
|
|
467
|
+
try {
|
|
468
|
+
const raw = await this.storage.getItem(LEGACY_KEY);
|
|
469
|
+
if (raw) {
|
|
470
|
+
const v1 = JSON.parse(raw);
|
|
471
|
+
saved = validSaved({ anon: v1?.anon, anonSource: "own", userId: v1?.userId ?? null, props: {}, optedOut: false, app: null,
|
|
472
|
+
queue: (Array.isArray(v1?.queue) ? v1.queue : []).map((e) => ({ ...e, a: v1.anon ?? "", u: v1.userId ?? null })) });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch { /* ignore */ }
|
|
476
|
+
}
|
|
477
|
+
const fresh = !saved?.anon;
|
|
478
|
+
let anon = saved?.anon ?? "";
|
|
479
|
+
let anonSource = saved?.anonSource ?? "own";
|
|
480
|
+
// Only on first launch: Mixpanel's distinct_id turns into the user id after login, and following it would split one
|
|
481
|
+
// person into two ids. The server matches logged-in people by user id instead.
|
|
482
|
+
if (fresh && cfg.getAnonymousId) {
|
|
483
|
+
try {
|
|
484
|
+
const id = await cfg.getAnonymousId();
|
|
485
|
+
if (id) {
|
|
486
|
+
anon = String(id).slice(0, 128);
|
|
487
|
+
anonSource = "provider";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch { /* keep the saved id */ }
|
|
491
|
+
}
|
|
492
|
+
if (!anon) {
|
|
493
|
+
anon = uid();
|
|
494
|
+
anonSource = "own";
|
|
495
|
+
}
|
|
496
|
+
const savedProps = saved?.props ?? {};
|
|
497
|
+
const savedUser = saved?.userId ?? null;
|
|
498
|
+
// A logout before the disk was read already started a new person: keep that, not the saved one.
|
|
499
|
+
if (!this.resetBeforeLoad) {
|
|
500
|
+
this.anon = anon;
|
|
501
|
+
this.anonSource = anonSource;
|
|
502
|
+
if (!this.userId && !this.identifiedBeforeLoad && savedUser)
|
|
503
|
+
this.userId = savedUser;
|
|
504
|
+
this.props = { ...savedProps, ...this.props };
|
|
505
|
+
}
|
|
506
|
+
if (this.consentBeforeLoad) {
|
|
507
|
+
this.optedOut = this.consentBeforeLoad === "out";
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
this.consented = saved?.consent === true;
|
|
511
|
+
this.optedOut = Boolean(saved?.optedOut) || (Boolean(cfg.optOutByDefault) && !this.consented);
|
|
512
|
+
}
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
const old = (saved?.queue ?? []).filter((e) => e && typeof e.n === "string" && typeof e.t === "number" && now - e.t < MAX_EVENT_AGE_MS);
|
|
515
|
+
// Events tracked while we were reading the disk belong after the saved ones, with the saved id, user and
|
|
516
|
+
// properties (they weren't known yet), unless a logout came first.
|
|
517
|
+
const dropped = new Set();
|
|
518
|
+
for (const e of this.queue) {
|
|
519
|
+
if (!e.a)
|
|
520
|
+
e.a = anon;
|
|
521
|
+
const early = this.early.get(e);
|
|
522
|
+
if (!early)
|
|
523
|
+
continue;
|
|
524
|
+
this.early.delete(e);
|
|
525
|
+
if (early.afterReset)
|
|
526
|
+
continue;
|
|
527
|
+
// The saved properties weren't known when it was tracked: clean it again, and let beforeSend see it again.
|
|
528
|
+
const kept = this.hook(early.name, cleanProps({ ...savedProps, ...early.props }), e.t);
|
|
529
|
+
if (!kept) {
|
|
530
|
+
dropped.add(e);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
e.n = kept.n;
|
|
534
|
+
if (kept.p)
|
|
535
|
+
e.p = kept.p;
|
|
536
|
+
else
|
|
537
|
+
delete e.p;
|
|
538
|
+
if (e.u == null && !early.afterIdentify && savedUser)
|
|
539
|
+
e.u = savedUser;
|
|
540
|
+
}
|
|
541
|
+
this.queue = [...old, ...this.queue.filter((e) => !dropped.has(e))];
|
|
542
|
+
this.loaded = true;
|
|
543
|
+
const current = this.device.app ? `${this.device.app} (${this.device.build ?? "?"})` : null;
|
|
544
|
+
if (fresh) {
|
|
545
|
+
this.enqueue("first_open");
|
|
546
|
+
void this.recordInstallSource();
|
|
547
|
+
}
|
|
548
|
+
else if (saved?.app && current && saved.app !== current)
|
|
549
|
+
this.enqueue("app_updated", { from_version: saved.app, to_version: current });
|
|
550
|
+
this.appVersion = current;
|
|
551
|
+
if (this.appState === "active")
|
|
552
|
+
this.open(true);
|
|
553
|
+
else
|
|
554
|
+
this.pendingOpen = true;
|
|
555
|
+
if (this.optedOut)
|
|
556
|
+
this.queue = [];
|
|
557
|
+
await this.persistNow();
|
|
558
|
+
}
|
|
559
|
+
/** Android only: the Play install referrer (what the store link carried, e.g. "utm_source=reddit&utm_campaign=launch"). iOS has none. */
|
|
560
|
+
async recordInstallSource() {
|
|
561
|
+
if (Platform.OS !== "android")
|
|
562
|
+
return;
|
|
563
|
+
const referrer = await installReferrer();
|
|
564
|
+
if (!referrer)
|
|
565
|
+
return;
|
|
566
|
+
const q = new URLSearchParams(referrer);
|
|
567
|
+
const source = q.get("utm_source");
|
|
568
|
+
if (!source)
|
|
569
|
+
return;
|
|
570
|
+
this.enqueue("install_source", { utm_source: source, utm_medium: q.get("utm_medium"), utm_campaign: q.get("utm_campaign") });
|
|
571
|
+
}
|
|
572
|
+
open(cold) {
|
|
573
|
+
this.pendingOpen = false;
|
|
574
|
+
this.activeSince = Date.now();
|
|
575
|
+
this.backgroundAt = null;
|
|
576
|
+
this.errorsThisVisit = 0;
|
|
577
|
+
this.enqueue("app_open", { cold });
|
|
578
|
+
}
|
|
579
|
+
onAppState(next) {
|
|
580
|
+
const prev = this.appState;
|
|
581
|
+
this.appState = next;
|
|
582
|
+
if (!this.loaded)
|
|
583
|
+
return; // restore() looks at the latest state when it finishes
|
|
584
|
+
if (next === "active") {
|
|
585
|
+
if (this.pendingOpen)
|
|
586
|
+
this.open(true);
|
|
587
|
+
else if (this.backgroundAt != null) {
|
|
588
|
+
const away = Date.now() - this.backgroundAt;
|
|
589
|
+
if (away > (this.cfg?.sessionTimeoutMinutes ?? 30) * 60000) {
|
|
590
|
+
this.sessionId = uid();
|
|
591
|
+
this.open(false);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
this.activeSince = Date.now();
|
|
595
|
+
this.backgroundAt = null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (this.retryReason === "network")
|
|
599
|
+
this.retryAt = 0; // the network may be back
|
|
600
|
+
void this.flush();
|
|
601
|
+
}
|
|
602
|
+
else if (next === "background") {
|
|
603
|
+
if (prev !== "background" && this.activeSince != null) {
|
|
604
|
+
this.enqueue("app_background", { seconds: Math.max(0, Math.round((Date.now() - this.activeSince) / 1000)) });
|
|
605
|
+
this.activeSince = null;
|
|
606
|
+
this.backgroundAt = Date.now();
|
|
607
|
+
}
|
|
608
|
+
void this.persistNow();
|
|
609
|
+
void this.flush();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
enqueue(name, props) {
|
|
613
|
+
const cfg = this.cfg;
|
|
614
|
+
if (!cfg)
|
|
615
|
+
return;
|
|
616
|
+
if (cfg.disabled) {
|
|
617
|
+
if (cfg.debug)
|
|
618
|
+
console.log("[apparent]", name, props ?? "");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (this.optedOut)
|
|
622
|
+
return;
|
|
623
|
+
const merged = { ...this.props, ...readProps(props) };
|
|
624
|
+
const t = Date.now();
|
|
625
|
+
const kept = this.hook(String(name).slice(0, 80), cleanProps(merged), t);
|
|
626
|
+
if (!kept)
|
|
627
|
+
return;
|
|
628
|
+
const e = { n: kept.n, t, s: this.sessionId, i: uid(), a: this.anon, u: this.userId };
|
|
629
|
+
if (kept.p)
|
|
630
|
+
e.p = kept.p;
|
|
631
|
+
if (!this.loaded)
|
|
632
|
+
this.early.set(e, { name: String(name).slice(0, 80), props: merged, afterReset: this.resetBeforeLoad, afterIdentify: this.identifiedBeforeLoad });
|
|
633
|
+
this.queue.push(e);
|
|
634
|
+
const max = cfg.maxQueue ?? 1000;
|
|
635
|
+
const over = this.queue.length + this.inflight.length - max;
|
|
636
|
+
if (over > 0)
|
|
637
|
+
this.queue.splice(0, over);
|
|
638
|
+
if (cfg.debug)
|
|
639
|
+
console.log("[apparent]", e.n, e.p ?? "");
|
|
640
|
+
this.schedulePersist();
|
|
641
|
+
if (this.queue.length >= (cfg.flushAt ?? 20))
|
|
642
|
+
void this.flush();
|
|
643
|
+
}
|
|
644
|
+
/** The app's beforeSend: the event it returns (cleaned again), or null when it drops it. A hook that throws keeps it. */
|
|
645
|
+
hook(n, p, t) {
|
|
646
|
+
const fn = this.cfg?.beforeSend;
|
|
647
|
+
if (!fn)
|
|
648
|
+
return { n, p };
|
|
649
|
+
let out;
|
|
650
|
+
try {
|
|
651
|
+
out = fn({ name: n, props: p ? { ...p } : undefined, time: t });
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
if (!this.warnedHook) {
|
|
655
|
+
this.warnedHook = true;
|
|
656
|
+
this.log("beforeSend threw; the event is sent as it was", err);
|
|
657
|
+
}
|
|
658
|
+
return { n, p };
|
|
659
|
+
}
|
|
660
|
+
if (!out || typeof out !== "object" || typeof out.name !== "string" || !out.name)
|
|
661
|
+
return null;
|
|
662
|
+
return { n: out.name.slice(0, 80), p: cleanProps(out.props) };
|
|
663
|
+
}
|
|
664
|
+
/** Record something a person did. Names: lowercase_snake. Props: flat values. */
|
|
665
|
+
track(name, props) {
|
|
666
|
+
if (!this.cfg || !isName(name))
|
|
667
|
+
return;
|
|
668
|
+
this.guard(() => this.enqueue(name, props));
|
|
669
|
+
}
|
|
670
|
+
/** Record that a screen was shown. Repeats of the current screen are ignored. */
|
|
671
|
+
screen(name, props) {
|
|
672
|
+
if (!this.cfg || !isName(name) || name === this.screenName)
|
|
673
|
+
return;
|
|
674
|
+
this.guard(() => {
|
|
675
|
+
const from = this.screenName;
|
|
676
|
+
this.screenName = name;
|
|
677
|
+
this.enqueue("screen_view", { ...readProps(props), screen: name, from: from ?? undefined });
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/** Report an error you caught yourself (an error boundary, a failed request). */
|
|
681
|
+
captureError(error, extra = {}) {
|
|
682
|
+
if (!this.cfg || this.errorsThisVisit >= MAX_ERRORS_PER_VISIT)
|
|
683
|
+
return;
|
|
684
|
+
this.errorsThisVisit++;
|
|
685
|
+
this.guard(() => {
|
|
686
|
+
const err = error instanceof Error ? error : new Error(typeof error === "string" ? error : String(cleanValue(error) ?? "Unknown error"));
|
|
687
|
+
const stack = String(err.stack ?? "").split("\n").slice(0, 8).map((l) => l.trim()).join("\n").slice(0, 1500);
|
|
688
|
+
const { fatal, how, ...rest } = readProps(extra);
|
|
689
|
+
this.enqueue("error", { ...rest, message: String(err.message || "Error").slice(0, 300), type: String(err.name || "Error"), fatal: Boolean(fatal), how: typeof how === "string" ? how : "reported", stack: stack || undefined, screen: this.screenName ?? undefined });
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/** Who is using the app, once they log in. `null` when they log out (or call reset()). */
|
|
693
|
+
identify(userId) {
|
|
694
|
+
this.guard(() => {
|
|
695
|
+
this.userId = userId ? String(userId).slice(0, 128) : null;
|
|
696
|
+
if (!this.loaded)
|
|
697
|
+
this.identifiedBeforeLoad = true;
|
|
698
|
+
// Saved now, not in a quarter second: an app killed right after a sign-in or logout must wake up as that person.
|
|
699
|
+
void this.persistNow();
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
/** Properties added to every event from now on, e.g. { language: "de", plan: "pro" }. `null` removes one. */
|
|
703
|
+
setProperties(props) {
|
|
704
|
+
for (const [k, v] of entriesOf(props)) {
|
|
705
|
+
const key = k.slice(0, 64);
|
|
706
|
+
const value = cleanValue(v);
|
|
707
|
+
if (value === undefined || value === null)
|
|
708
|
+
delete this.props[key];
|
|
709
|
+
else
|
|
710
|
+
this.props[key] = value;
|
|
711
|
+
}
|
|
712
|
+
this.schedulePersist();
|
|
713
|
+
}
|
|
714
|
+
unsetProperty(key) {
|
|
715
|
+
delete this.props[key];
|
|
716
|
+
this.schedulePersist();
|
|
717
|
+
}
|
|
718
|
+
/** Someone logged out: forget who they were and start a new anonymous id. */
|
|
719
|
+
reset() {
|
|
720
|
+
if (!this.loaded)
|
|
721
|
+
this.resetBeforeLoad = true;
|
|
722
|
+
this.anon = uid();
|
|
723
|
+
this.anonSource = "reset";
|
|
724
|
+
this.userId = null;
|
|
725
|
+
this.props = {};
|
|
726
|
+
this.sessionId = uid();
|
|
727
|
+
this.screenName = null;
|
|
728
|
+
void this.persistNow();
|
|
729
|
+
}
|
|
730
|
+
/** Stop collecting (for example when someone declines analytics). Remembered across launches. */
|
|
731
|
+
optOut() {
|
|
732
|
+
this.optedOut = true;
|
|
733
|
+
this.consented = false;
|
|
734
|
+
if (!this.loaded)
|
|
735
|
+
this.consentBeforeLoad = "out";
|
|
736
|
+
this.queue = [];
|
|
737
|
+
void this.persistNow();
|
|
738
|
+
}
|
|
739
|
+
/** Start collecting again, or for the first time with optOutByDefault. Remembered across launches. */
|
|
740
|
+
optIn() {
|
|
741
|
+
this.optedOut = false;
|
|
742
|
+
this.consented = true;
|
|
743
|
+
if (!this.loaded)
|
|
744
|
+
this.consentBeforeLoad = "in";
|
|
745
|
+
void this.persistNow();
|
|
746
|
+
}
|
|
747
|
+
/** Resolves once saved events and ids are loaded (anonymousId is final from then on). */
|
|
748
|
+
ready() {
|
|
749
|
+
return this.loading;
|
|
750
|
+
}
|
|
751
|
+
get isOptedOut() {
|
|
752
|
+
return this.optedOut;
|
|
753
|
+
}
|
|
754
|
+
get anonymousId() {
|
|
755
|
+
return this.anon;
|
|
756
|
+
}
|
|
757
|
+
/** Store this person's Apparent id on their RevenueCat customer, so purchases join to what they did. */
|
|
758
|
+
async linkRevenueCat(purchases) {
|
|
759
|
+
await this.loading;
|
|
760
|
+
try {
|
|
761
|
+
await purchases.setAttributes({ apparent_id: this.anon });
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
this.log("could not link RevenueCat", e);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Send what is queued now. Safe to call any time: only one send runs at once. */
|
|
768
|
+
flush() {
|
|
769
|
+
if (!this.cfg)
|
|
770
|
+
return Promise.resolve();
|
|
771
|
+
if (this.sending) {
|
|
772
|
+
this.sendAgain = true;
|
|
773
|
+
return this.sending;
|
|
774
|
+
}
|
|
775
|
+
this.sending = this.drain().finally(() => {
|
|
776
|
+
this.sending = null;
|
|
777
|
+
if (this.sendAgain) {
|
|
778
|
+
this.sendAgain = false;
|
|
779
|
+
if (this.queue.length && this.due())
|
|
780
|
+
void this.flush();
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
return this.sending;
|
|
784
|
+
}
|
|
785
|
+
async drain() {
|
|
786
|
+
await this.loading;
|
|
787
|
+
const cfg = this.cfg;
|
|
788
|
+
if (cfg.disabled || this.optedOut) {
|
|
789
|
+
this.queue = [];
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const cutoff = Date.now() - MAX_EVENT_AGE_MS;
|
|
793
|
+
if (this.queue.some((e) => e.t < cutoff))
|
|
794
|
+
this.queue = this.queue.filter((e) => e.t >= cutoff);
|
|
795
|
+
let sent = false;
|
|
796
|
+
while (this.queue.length && this.due()) {
|
|
797
|
+
const batch = this.queue.splice(0, this.batchSize);
|
|
798
|
+
this.inflight = batch;
|
|
799
|
+
// Nothing may be in flight without being on disk: a crash mid-send must not lose it.
|
|
800
|
+
if (this.persistTimer)
|
|
801
|
+
await this.persistNow();
|
|
802
|
+
const result = await this.send(batch);
|
|
803
|
+
this.inflight = [];
|
|
804
|
+
sent = true;
|
|
805
|
+
if (result.kind === "ok" || result.kind === "drop") {
|
|
806
|
+
if (result.kind === "drop") {
|
|
807
|
+
this.log(`server refused ${batch.length} events (${result.status})`);
|
|
808
|
+
// Retrying never fixes a wrong key, so the batch is dropped; say so once, loudly, while developing.
|
|
809
|
+
if (result.status === 401 && !this.warnedKey && (cfg.debug || isDev())) {
|
|
810
|
+
this.warnedKey = true;
|
|
811
|
+
console.warn(WRONG_KEY_WARNING);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
this.failures = 0;
|
|
815
|
+
this.retryAt = 0;
|
|
816
|
+
this.retryWait = 0;
|
|
817
|
+
this.retryReason = null;
|
|
818
|
+
if (result.kind === "ok" && this.batchSize < MAX_BATCH)
|
|
819
|
+
this.batchSize = Math.min(MAX_BATCH, this.batchSize * 2);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (result.kind === "too-large") {
|
|
823
|
+
if (batch.length === 1) {
|
|
824
|
+
this.log("dropped one event the server says is too large");
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
this.batchSize = Math.max(1, Math.floor(batch.length / 2));
|
|
828
|
+
this.queue.unshift(...batch);
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
this.queue.unshift(...batch);
|
|
832
|
+
this.failures++;
|
|
833
|
+
this.retryReason = result.kind === "network" ? "network" : "server";
|
|
834
|
+
const base = cfg.retryBaseMs ?? 2000;
|
|
835
|
+
// The server's Retry-After exactly (already 1 s .. 1 h), never earlier; our own backoff with +-20 % jitter.
|
|
836
|
+
this.retryWait = result.retryAfterMs ?? Math.round(Math.min(5 * 60000, base * 2 ** (this.failures - 1)) * (0.8 + Math.random() * 0.4));
|
|
837
|
+
this.retryAt = Date.now() + this.retryWait;
|
|
838
|
+
this.log(`send failed (${result.status ?? result.kind}), next try in ${Math.round((this.retryAt - Date.now()) / 1000)} s`);
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
if (sent)
|
|
842
|
+
await this.persistNow();
|
|
843
|
+
}
|
|
844
|
+
/** True once the retry wait is over. A wait that is now longer than it was set to means the phone's clock went back:
|
|
845
|
+
* it's over too, so a clock change never holds the queue (PostHog and Amplitude test the same). */
|
|
846
|
+
due() {
|
|
847
|
+
const now = Date.now();
|
|
848
|
+
if (this.retryAt - now > this.retryWait)
|
|
849
|
+
this.retryAt = now;
|
|
850
|
+
return now >= this.retryAt;
|
|
851
|
+
}
|
|
852
|
+
async send(batch) {
|
|
853
|
+
const cfg = this.cfg;
|
|
854
|
+
const first = batch[0];
|
|
855
|
+
const d = { ...this.device, anon: first.a || this.anon, user: first.u };
|
|
856
|
+
const e = batch.map(({ a, u, ...rest }) => {
|
|
857
|
+
const out = rest;
|
|
858
|
+
if (a && a !== d.anon)
|
|
859
|
+
out.a = a;
|
|
860
|
+
if (u !== d.user)
|
|
861
|
+
out.u = u;
|
|
862
|
+
return out;
|
|
863
|
+
});
|
|
864
|
+
const body = JSON.stringify({ a: cfg.app, k: cfg.key, v: SDK_VERSION, sa: Date.now(), d, e });
|
|
865
|
+
const ms = cfg.requestTimeoutMs ?? 10000;
|
|
866
|
+
const ctrl = typeof AbortController === "function" ? new AbortController() : null;
|
|
867
|
+
let timer;
|
|
868
|
+
const timeout = new Promise((_, reject) => {
|
|
869
|
+
timer = setTimeout(() => {
|
|
870
|
+
ctrl?.abort();
|
|
871
|
+
reject(new Error("timeout"));
|
|
872
|
+
}, ms);
|
|
873
|
+
});
|
|
874
|
+
try {
|
|
875
|
+
const res = await Promise.race([
|
|
876
|
+
fetch(`${cfg.apiUrl.replace(/\/$/, "")}/i`, { method: "POST", headers: { "content-type": "application/json" }, body, signal: ctrl?.signal }),
|
|
877
|
+
timeout,
|
|
878
|
+
]);
|
|
879
|
+
if (res.ok)
|
|
880
|
+
return { kind: "ok" };
|
|
881
|
+
if (res.status === 413)
|
|
882
|
+
return { kind: "too-large", status: 413 };
|
|
883
|
+
if (res.status === 408 || res.status === 429 || res.status >= 500) {
|
|
884
|
+
return { kind: "server", status: res.status, retryAfterMs: retryAfterMs(res.headers?.get?.("retry-after")) };
|
|
885
|
+
}
|
|
886
|
+
return { kind: "drop", status: res.status };
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return { kind: "network" };
|
|
890
|
+
}
|
|
891
|
+
finally {
|
|
892
|
+
clearTimeout(timer);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
schedulePersist() {
|
|
896
|
+
if (this.persistTimer || !this.loaded)
|
|
897
|
+
return;
|
|
898
|
+
this.persistTimer = setTimeout(() => {
|
|
899
|
+
this.persistTimer = null;
|
|
900
|
+
void this.persistNow();
|
|
901
|
+
}, 250);
|
|
902
|
+
}
|
|
903
|
+
async persistNow() {
|
|
904
|
+
if (this.persistTimer) {
|
|
905
|
+
clearTimeout(this.persistTimer);
|
|
906
|
+
this.persistTimer = null;
|
|
907
|
+
}
|
|
908
|
+
if (!this.cfg || this.cfg.disabled || !this.loaded)
|
|
909
|
+
return;
|
|
910
|
+
const state = {
|
|
911
|
+
v: 2, anon: this.anon, anonSource: this.anonSource, userId: this.userId, props: this.props, optedOut: this.optedOut,
|
|
912
|
+
...(this.consented ? { consent: true } : {}),
|
|
913
|
+
app: this.appVersion, queue: [...this.inflight, ...this.queue].slice(-(this.cfg.maxQueue ?? 1000)),
|
|
914
|
+
};
|
|
915
|
+
try {
|
|
916
|
+
// A disk that never answers must not hold sending or ready(): stop waiting after a second (the write goes on).
|
|
917
|
+
let timer;
|
|
918
|
+
await Promise.race([
|
|
919
|
+
this.storage.setItem(STORAGE_KEY, JSON.stringify(state)),
|
|
920
|
+
new Promise((resolve) => { timer = setTimeout(resolve, WRITE_WAIT_MS); }),
|
|
921
|
+
]).finally(() => clearTimeout(timer));
|
|
922
|
+
}
|
|
923
|
+
catch (e) {
|
|
924
|
+
this.log("could not save events", e);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
installErrorHandlers() {
|
|
928
|
+
const g = globalThis;
|
|
929
|
+
if (g.__apparentErrors)
|
|
930
|
+
return;
|
|
931
|
+
g.__apparentErrors = true;
|
|
932
|
+
const eu = g.ErrorUtils;
|
|
933
|
+
if (eu?.getGlobalHandler && eu.setGlobalHandler) {
|
|
934
|
+
const previous = eu.getGlobalHandler();
|
|
935
|
+
eu.setGlobalHandler((error, isFatal) => {
|
|
936
|
+
try {
|
|
937
|
+
this.captureError(error, { fatal: Boolean(isFatal), how: isFatal ? "crash" : "uncaught" });
|
|
938
|
+
}
|
|
939
|
+
catch { /* never make it worse */ }
|
|
940
|
+
if (!isFatal) {
|
|
941
|
+
previous?.(error, isFatal);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// The app is about to die: give the save a moment, then let the crash continue.
|
|
945
|
+
let done = false;
|
|
946
|
+
const finish = () => {
|
|
947
|
+
if (done)
|
|
948
|
+
return;
|
|
949
|
+
done = true;
|
|
950
|
+
previous?.(error, isFatal);
|
|
951
|
+
};
|
|
952
|
+
this.persistNow().then(finish, finish);
|
|
953
|
+
setTimeout(finish, 300);
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
// In development React Native shows its own warnings for these; only take over in release builds.
|
|
957
|
+
const tracker = g.HermesInternal?.enablePromiseRejectionTracker;
|
|
958
|
+
if (typeof tracker === "function" && !isDev()) {
|
|
959
|
+
try {
|
|
960
|
+
tracker({ allRejections: true, onUnhandled: (_id, rejection) => this.captureError(rejection, { how: "promise" }), onHandled: () => { } });
|
|
961
|
+
}
|
|
962
|
+
catch { /* not supported */ }
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/** Stop timers and listeners, after saving what is queued. For tests and hot reload. */
|
|
966
|
+
async shutdown() {
|
|
967
|
+
if (this.interval)
|
|
968
|
+
clearInterval(this.interval);
|
|
969
|
+
this.interval = null;
|
|
970
|
+
this.appStateSub?.remove();
|
|
971
|
+
this.appStateSub = null;
|
|
972
|
+
this.linkSub?.remove();
|
|
973
|
+
this.linkSub = null;
|
|
974
|
+
this.pushSub?.remove();
|
|
975
|
+
this.pushSub = null;
|
|
976
|
+
await this.loading;
|
|
977
|
+
await this.persistNow();
|
|
978
|
+
}
|
|
979
|
+
/** Public methods never throw into the app (SPEC 6). */
|
|
980
|
+
guard(fn) {
|
|
981
|
+
try {
|
|
982
|
+
fn();
|
|
983
|
+
}
|
|
984
|
+
catch (e) {
|
|
985
|
+
this.log("ignored a call that failed", e);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
log(message, detail) {
|
|
989
|
+
if (this.cfg?.debug)
|
|
990
|
+
console.warn(`[apparent] ${message}`, detail ?? "");
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
export const Apparent = new ApparentClient();
|
|
994
|
+
export default Apparent;
|
|
995
|
+
//# sourceMappingURL=index.js.map
|