apparent-capacitor 0.0.0-stage → 0.2.2

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