@wireai/activation 0.13.6 → 0.14.1
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/AGENTS.md +21 -9
- package/CHANGELOG.md +227 -1
- package/INTEGRATION_PROMPT.md +7 -4
- package/README.md +15 -2
- package/dist/analytics/index.d.mts +9 -2
- package/dist/analytics/index.d.ts +9 -2
- package/dist/analytics/index.js +56 -12
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +56 -12
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-DngW-QoD.d.mts → currentSession-CUvTOchb.d.mts} +35 -6
- package/dist/{currentSession-C5976akx.d.ts → currentSession-CW_5Mq4O.d.ts} +35 -6
- package/dist/index.d.mts +31 -7
- package/dist/index.d.ts +31 -7
- package/dist/index.js +643 -539
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +643 -539
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +14 -6
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +14 -6
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +14 -6
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +14 -6
- package/dist/reviews/index.mjs.map +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +4 -3
- package/src/WireOnboarding.tsx +48 -8
- package/src/activation/useWireActivation.ts +14 -1
- package/src/activation/wireActivation.ts +68 -2
- package/src/analytics/analyticsFacade.ts +24 -0
- package/src/analytics/currentSession.ts +3 -2
- package/src/analytics/eventQueue.ts +106 -11
- package/src/analytics/reportClientEvent.ts +31 -7
- package/src/analytics/useAnalytics.ts +17 -0
- package/src/context/deviceId.ts +10 -3
- package/src/permissions/permissionMemory.ts +41 -5
- package/src/reviews/runtime.ts +75 -23
- package/src/session/persistedSession.ts +32 -8
- package/src/session-analytics/lifecycle.ts +26 -5
- package/src/session-analytics/reportSessionStart.ts +14 -10
- package/src/session-analytics/useLifecycleEvents.ts +70 -32
- package/src/session-analytics/useSessionStart.ts +57 -15
- package/src/types.ts +16 -6
- package/src/utils/readPlan.ts +8 -5
package/llms.txt
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
- **Metro (required):** `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from `@wireai/activation/metro`, which pins one copy of react/react-native/wireai-rn/zod (prevents the dual-React crash).
|
|
13
13
|
- **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Created in the getwireai console or via the backend's `register_<app>.py`. Nothing renders without both.
|
|
14
14
|
- **Render:** drop `<WireOnboarding config={wireConfigFromEnv({ appId })} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} />` into the signup flow.
|
|
15
|
-
- **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding
|
|
16
|
-
- **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open
|
|
15
|
+
- **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>`, or, when the app owns no device id, pass a working `storage` and leave `userContext` alone so the kit injects its own (⛔ never hand-build it from the synchronous `resolveAutoDeviceKey`, which cannot report whether the id survives the launch). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
|
|
16
|
+
- **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. Storage that throws or rejects counts as no storage since 0.14.0: the events fire with no auto key rather than a per-launch one. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and `useSessionStart` follows the same auto-key rule since 0.14.0.
|
|
17
17
|
- **Capture is deterministic.** `onComplete` returns an `OnboardingResult`: `answers` and `raw` always, plus (since 0.13.6) `plan` when the backend sent an onboarding plan and `variant` when the tenant runs an experiment and the backend assigned an arm. No second LLM call. Persist all answers through the app's normal profile-update path. Those two extra keys are set ONLY when the backend actually sent them, so a run without them returns exactly the object earlier versions returned, and the kit interprets neither: validate `plan` before applying it.
|
|
18
18
|
- **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
|
|
19
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -381,9 +381,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
381
381
|
if (finished.current) return;
|
|
382
382
|
finished.current = true;
|
|
383
383
|
const result: OnboardingResult = { answers: deriveAnswers(messages), raw: messages };
|
|
384
|
-
// The plan rides a second DataPart
|
|
385
|
-
// the key ONLY when there is one keeps the no-plan
|
|
386
|
-
// object — a host that inspects `Object.keys(result)` or
|
|
384
|
+
// The plan rides a second DataPart and is present when the backend sent one — the kit never
|
|
385
|
+
// infers it from which flow ran. Setting the key ONLY when there is one keeps the no-plan
|
|
386
|
+
// result byte-identical to the pre-plan object — a host that inspects `Object.keys(result)` or
|
|
387
|
+
// `"plan" in result` sees no change.
|
|
387
388
|
const plan = readPlan(messages);
|
|
388
389
|
if (plan !== undefined) result.plan = plan;
|
|
389
390
|
// The experiment arm, latched off the render envelopes during the flow. Set under exactly the
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
} from "./session/persistedSession";
|
|
37
37
|
import {
|
|
38
38
|
clearSettledPermissions,
|
|
39
|
-
|
|
39
|
+
loadSettledPermissionsOutcome,
|
|
40
40
|
permissionStorageKey,
|
|
41
41
|
saveSettledPermissions,
|
|
42
42
|
} from "./permissions/permissionMemory";
|
|
@@ -254,9 +254,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
254
254
|
warnInDev(
|
|
255
255
|
"[wireai] <WireOnboarding> got no user_context.device_key, so this onboarding session can " +
|
|
256
256
|
"never be joined to the app's later events and the `activated` funnel will read zero. Pass " +
|
|
257
|
-
"userContext={activationJoinContext(deviceKey)}
|
|
258
|
-
"userContext
|
|
259
|
-
"
|
|
257
|
+
"userContext={activationJoinContext(deviceKey)} if your app owns a device id. If it does " +
|
|
258
|
+
"NOT, do not build one here — leave userContext alone and pass a working `storage` instead: " +
|
|
259
|
+
"the kit injects the same id the analytics side stamps, and only when it has confirmed the " +
|
|
260
|
+
"id actually persists. Never hand-write userContext={{ deviceKey }}. " +
|
|
260
261
|
"The kit did NOT auto-inject its own key here because " +
|
|
261
262
|
autoJoinReason,
|
|
262
263
|
);
|
|
@@ -383,18 +384,57 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
383
384
|
// a late arrival cannot recreate the A2A adapter. The flow simply suppresses permission screens
|
|
384
385
|
// while `permissionsPending` is true, and that window closes long before the first card arrives
|
|
385
386
|
// (one timeout-capped storage read against a backend round trip).
|
|
387
|
+
//
|
|
388
|
+
// ── AN UNREADABLE RECORD IS NOT AN EMPTY ONE (ruled 2026-08-18: skip, never re-ask) ──────────
|
|
389
|
+
//
|
|
390
|
+
// A read that timed out or threw used to arrive here as `[]`, i.e. "this session has settled
|
|
391
|
+
// nothing", and that guess is wrong in the expensive direction TWICE. The flow re-asks a
|
|
392
|
+
// permission the OS grants exactly once, AND the first answer then persists a set grown from
|
|
393
|
+
// that `[]`, overwriting a stored record that may have held several ids with a single one. A
|
|
394
|
+
// slow adapter could therefore permanently shrink the memory it exists to keep.
|
|
395
|
+
//
|
|
396
|
+
// So `unknown` is now its own outcome and it means: leave the memory UNRESOLVED. Because
|
|
397
|
+
// `permissionsPending` below is exactly `settledPermissions === undefined`, an unresolved memory
|
|
398
|
+
// suppresses every permission screen for this mount — which is the ruling, and it also closes
|
|
399
|
+
// the write path for free (no screen renders → nothing settles → `saveSettledPermissions` is
|
|
400
|
+
// never called → the stored record is left exactly as it was).
|
|
401
|
+
//
|
|
402
|
+
// The cost is stated rather than hidden: on a host whose adapter never answers, configured
|
|
403
|
+
// permission screens silently never show. That is the failure `placement.ts` calls out as the
|
|
404
|
+
// one a host cannot see, so it warns in dev naming the adapter.
|
|
386
405
|
const wantsPermissionMemory = Boolean(storage) && (permissionScreens?.length ?? 0) > 0;
|
|
387
406
|
const [settledPermissions, setSettledPermissions] = useState<string[] | undefined>(undefined);
|
|
407
|
+
// LATCHED: without it, a hung or throwing adapter is re-read on every single render — an
|
|
408
|
+
// unbounded read loop against the storage that is already failing.
|
|
409
|
+
const [permissionMemoryUnreadable, setPermissionMemoryUnreadable] = useState(false);
|
|
388
410
|
useEffect(() => {
|
|
389
|
-
if (!wantsPermissionMemory || !storage || !sessionId
|
|
411
|
+
if (!wantsPermissionMemory || !storage || !sessionId) return;
|
|
412
|
+
if (settledPermissions !== undefined || permissionMemoryUnreadable) return;
|
|
390
413
|
let cancelled = false;
|
|
391
|
-
void
|
|
392
|
-
if (
|
|
414
|
+
void loadSettledPermissionsOutcome(storage, permissionsKey, sessionId).then((outcome) => {
|
|
415
|
+
if (cancelled) return;
|
|
416
|
+
if (outcome.status === "read") {
|
|
417
|
+
setSettledPermissions(outcome.ids);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
setPermissionMemoryUnreadable(true);
|
|
421
|
+
warnInDev(
|
|
422
|
+
"[wireai] the permission-screen memory could not be read (the storage adapter timed out " +
|
|
423
|
+
"or threw), so permission screens are suppressed for this session rather than re-asking " +
|
|
424
|
+
"a permission the OS grants once. Check the `storage` adapter passed to WireOnboarding.",
|
|
425
|
+
);
|
|
393
426
|
});
|
|
394
427
|
return () => {
|
|
395
428
|
cancelled = true;
|
|
396
429
|
};
|
|
397
|
-
}, [
|
|
430
|
+
}, [
|
|
431
|
+
wantsPermissionMemory,
|
|
432
|
+
storage,
|
|
433
|
+
permissionsKey,
|
|
434
|
+
sessionId,
|
|
435
|
+
settledPermissions,
|
|
436
|
+
permissionMemoryUnreadable,
|
|
437
|
+
]);
|
|
398
438
|
|
|
399
439
|
// Persist the settled set as it grows. Fire-and-forget: a failed write only costs a re-ask on a
|
|
400
440
|
// resume, never a broken flow. Without `storage` there is nothing to write to and the
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* `useScreenTracking`) split: the factory stays React-free; this is the glue. The instance is built
|
|
17
17
|
* once per mount and held in a ref, so re-renders never rebuild it or re-resolve the device key.
|
|
18
18
|
*/
|
|
19
|
-
import { useRef, useSyncExternalStore } from "react";
|
|
19
|
+
import { useEffect, useRef, useSyncExternalStore } from "react";
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
getActivationRevalidationVersion,
|
|
@@ -73,9 +73,22 @@ export const useWireActivation = (config: WireActivationConfig): UseWireActivati
|
|
|
73
73
|
].join("|");
|
|
74
74
|
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
75
75
|
prevKeys.current = currentKeys;
|
|
76
|
+
// Hand back the instance being replaced: a `track()` that failed while offline may have opened a
|
|
77
|
+
// durability queue, which owns a storage claim and a backoff timer. See `WireActivation.dispose`.
|
|
78
|
+
ref.current?.dispose();
|
|
76
79
|
ref.current = createWireActivation(config);
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
// UNMOUNT: same reason. A per-screen instance that buffered a failed action must not leave its
|
|
83
|
+
// queue holding the slot the next instance needs.
|
|
84
|
+
useEffect(
|
|
85
|
+
() => () => {
|
|
86
|
+
ref.current?.dispose();
|
|
87
|
+
ref.current = undefined;
|
|
88
|
+
},
|
|
89
|
+
[],
|
|
90
|
+
);
|
|
91
|
+
|
|
79
92
|
const revalidation = useActivationRevalidation();
|
|
80
93
|
return { track: ref.current.track, sessionId: ref.current.sessionId, revalidation };
|
|
81
94
|
};
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { ensureCurrentSessionId, getCurrentSessionId } from "../analytics/currentSession";
|
|
26
26
|
import {
|
|
27
|
-
|
|
27
|
+
reportClientEventsOutcome,
|
|
28
28
|
type ClientEvent,
|
|
29
29
|
type ClientEventTarget,
|
|
30
30
|
} from "../analytics/reportClientEvent";
|
|
31
|
+
import { createEventQueue, type EventQueue } from "../analytics/eventQueue";
|
|
31
32
|
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
32
33
|
import { cleanString, resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
33
34
|
import { resolveIdentity } from "../identity/identityRecord";
|
|
@@ -69,6 +70,15 @@ export type WireActivationConfig = {
|
|
|
69
70
|
appVersion?: string;
|
|
70
71
|
/** Host persistence (AsyncStorage-compatible subset) so the auto-minted device key survives launches. */
|
|
71
72
|
storage?: WireOnboardingStorage;
|
|
73
|
+
/**
|
|
74
|
+
* Where a FAILED `track()` goes instead of being dropped: your existing offline queue's `enqueue`
|
|
75
|
+
* (the same wiring `useLifecycleEvents` takes). Pass it when you already run one queue for the app
|
|
76
|
+
* and you want one backlog, one retry schedule, one storage slot.
|
|
77
|
+
*
|
|
78
|
+
* Omit it and the instance opens its OWN queue the first time a POST fails — never before, so an
|
|
79
|
+
* app that is online the whole time allocates nothing. A successful `track()` never touches either.
|
|
80
|
+
*/
|
|
81
|
+
sink?: (event: ClientEvent) => void;
|
|
72
82
|
};
|
|
73
83
|
|
|
74
84
|
/** The kit-owned activation surface. `sessionId` is a live getter (reads `getCurrentSessionId()`). */
|
|
@@ -92,6 +102,12 @@ export type WireActivation = {
|
|
|
92
102
|
subscribeRevalidation(listener: () => void): () => void;
|
|
93
103
|
/** The current revalidation version — include in a decision-fetch effect's deps to re-fetch on bump. */
|
|
94
104
|
getRevalidationVersion(): number;
|
|
105
|
+
/**
|
|
106
|
+
* Tear down the durability queue a failed `track()` may have opened (see
|
|
107
|
+
* {@link EventQueue.dispose}). Idempotent, never throws, and a no-op on an instance that never had
|
|
108
|
+
* a failure. Call it when you build an instance per mount; `useWireActivation` does it for you.
|
|
109
|
+
*/
|
|
110
|
+
dispose(): void;
|
|
95
111
|
};
|
|
96
112
|
|
|
97
113
|
/**
|
|
@@ -147,6 +163,36 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
147
163
|
if (resolved.userId && !event.user_id) event.user_id = resolved.userId;
|
|
148
164
|
};
|
|
149
165
|
|
|
166
|
+
/**
|
|
167
|
+
* The durability path for a FAILED `track()`, built on the first failure and never before.
|
|
168
|
+
*
|
|
169
|
+
* WHY LAZY: an app that is online the whole time should allocate no queue, claim no storage slot
|
|
170
|
+
* and arm no timer. The queue exists to catch the events a bare POST used to destroy.
|
|
171
|
+
*
|
|
172
|
+
* WHY AN EXPLICIT KEY: it is a slot this instance OWNS, distinct from the façade's
|
|
173
|
+
* `wireai:evtq:<appId>`, and an explicit key is rotation-exempt — a remount's replacement instance
|
|
174
|
+
* addresses the same backlog instead of a `…#2` nobody drains. `dispose()` hands it back.
|
|
175
|
+
*
|
|
176
|
+
* AT-LEAST-ONCE, deliberately: a POST whose response was lost (an abort, a 15s timeout) may have
|
|
177
|
+
* reached the server, so a retry can duplicate it. That is the trade this kit already makes on
|
|
178
|
+
* every other event family, and the direction to fail in for a trigger that must not undercount.
|
|
179
|
+
*/
|
|
180
|
+
let durability: EventQueue | undefined;
|
|
181
|
+
const bufferFailedEvent = (event: ClientEvent): void => {
|
|
182
|
+
if (config.sink) {
|
|
183
|
+
config.sink(event);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!durability) {
|
|
187
|
+
durability = createEventQueue({
|
|
188
|
+
target,
|
|
189
|
+
storage: config.storage,
|
|
190
|
+
storageKey: `wireai:evtq:activation:${config.appId ?? "default"}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
durability.enqueue(event);
|
|
194
|
+
};
|
|
195
|
+
|
|
150
196
|
const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
|
|
151
197
|
// A blank name is the only thing left to refuse on: there is no `question_key` to match a
|
|
152
198
|
// firing trigger against, so bail WITHOUT bumping (a bump with no posted event would only make
|
|
@@ -164,9 +210,25 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
164
210
|
};
|
|
165
211
|
if (meta && Object.keys(meta).length > 0) event.meta = JSON.stringify(meta);
|
|
166
212
|
applyContext(event);
|
|
167
|
-
const
|
|
213
|
+
const outcome = await reportClientEventsOutcome(target, [event]);
|
|
214
|
+
const ok = outcome === "delivered";
|
|
168
215
|
// Only revalidate once the event is actually in the stream — a failed POST leaves the gate as-is.
|
|
169
216
|
if (ok) bumpActivationRevalidation();
|
|
217
|
+
// A failed POST used to end the event's life right here. `track` is the ACTION reporter, and it
|
|
218
|
+
// is called at exactly the moments a phone is offline (a purchase on a train, a trial in a lift),
|
|
219
|
+
// so the server-side trigger keyed on that `question_key` simply never fired for that user. The
|
|
220
|
+
// event now goes to a durable buffer instead, and rides the same retry + persistence every other
|
|
221
|
+
// event family in this kit already gets.
|
|
222
|
+
//
|
|
223
|
+
// The RETURN VALUE stays `false`, deliberately: the caller asked whether the server has it NOW,
|
|
224
|
+
// and it does not. Revalidation stays conservative for the same reason — a deferred delivery does
|
|
225
|
+
// not bump it, so a gate re-fetch is never triggered by an event the server may still reject.
|
|
226
|
+
//
|
|
227
|
+
// ⛔ ONLY `unreachable`. A `refused` event is one the server READ and declined (a 200 carrying
|
|
228
|
+
// `skipped`), so re-sending it is a re-decline: it would sit in the PERSISTED backlog and be
|
|
229
|
+
// rejected again on every drain and every launch, forever. Durability is for the events the
|
|
230
|
+
// network lost, never for the ones the server rejected.
|
|
231
|
+
if (outcome === "unreachable") bufferFailedEvent(event);
|
|
170
232
|
return ok;
|
|
171
233
|
};
|
|
172
234
|
|
|
@@ -177,5 +239,9 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
177
239
|
},
|
|
178
240
|
subscribeRevalidation: subscribeActivationRevalidation,
|
|
179
241
|
getRevalidationVersion: getActivationRevalidationVersion,
|
|
242
|
+
dispose: () => {
|
|
243
|
+
durability?.dispose();
|
|
244
|
+
durability = undefined;
|
|
245
|
+
},
|
|
180
246
|
};
|
|
181
247
|
};
|
|
@@ -140,6 +140,13 @@ export type Analytics = {
|
|
|
140
140
|
notifyOnline(): void;
|
|
141
141
|
/** Current pending (in-memory) count. */
|
|
142
142
|
size(): number;
|
|
143
|
+
/**
|
|
144
|
+
* Tear the instance's event queue down when its owner goes away (see {@link EventQueue.dispose}).
|
|
145
|
+
* Idempotent, never throws. Call it if you build an instance per screen / per mount: the queue
|
|
146
|
+
* claims a persisted storage slot, and a replacement built before the old one released it lands on
|
|
147
|
+
* a rotated `…#2` key that no later launch ever reads. `useAnalytics` does this for you.
|
|
148
|
+
*/
|
|
149
|
+
dispose(): void;
|
|
143
150
|
};
|
|
144
151
|
|
|
145
152
|
/**
|
|
@@ -245,10 +252,22 @@ export const createAnalytics = (
|
|
|
245
252
|
let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
|
|
246
253
|
const storageKey = analyticsUserIdStorageKey(config.appId);
|
|
247
254
|
|
|
255
|
+
// SUPERSESSION LATCH for the construction-time hydration below.
|
|
256
|
+
//
|
|
257
|
+
// The read's guard used to be `saved && !boundUserId` alone, which asks "did something bind an id
|
|
258
|
+
// while I was reading?". A LOGOUT is the one case where the answer is a deliberate `undefined`, so
|
|
259
|
+
// a read that landed after `reset()` sailed through the guard and re-bound the user who had just
|
|
260
|
+
// logged out — onto every subsequent event, which is precisely what `reset()` promises cannot
|
|
261
|
+
// happen. The window is the first storage read of a cold start, and a logout inside it is a real
|
|
262
|
+
// sequence, not a contrived one. An empty binding must be able to mean "cleared on purpose".
|
|
263
|
+
let hydrationSuperseded = false;
|
|
264
|
+
|
|
248
265
|
if (config.storage) {
|
|
249
266
|
void config.storage
|
|
250
267
|
.getItem(storageKey)
|
|
251
268
|
.then((saved) => {
|
|
269
|
+
// A `reset()` that already ran wins, whatever this read says.
|
|
270
|
+
if (hydrationSuperseded) return;
|
|
252
271
|
// Don't clobber an explicit init-context user id with a stale persisted one.
|
|
253
272
|
if (saved && !boundUserId) boundUserId = saved;
|
|
254
273
|
})
|
|
@@ -311,6 +330,10 @@ export const createAnalytics = (
|
|
|
311
330
|
};
|
|
312
331
|
|
|
313
332
|
const reset = (): void => {
|
|
333
|
+
// Supersede any construction-time hydration still in flight, BEFORE clearing the binding: the
|
|
334
|
+
// read cannot tell "nobody bound anything yet" from "the user just logged out", so the answer
|
|
335
|
+
// has to come from here. Without this the read re-binds the logged-out user.
|
|
336
|
+
hydrationSuperseded = true;
|
|
314
337
|
// In-memory binding cleared: subsequent events carry no user_id until the next identify.
|
|
315
338
|
boundUserId = undefined;
|
|
316
339
|
// Strip PII from the rich context but keep the device-scope fields (device_key / app_version).
|
|
@@ -377,5 +400,6 @@ export const createAnalytics = (
|
|
|
377
400
|
flush: queue.flush,
|
|
378
401
|
notifyOnline: queue.notifyOnline,
|
|
379
402
|
size: queue.size,
|
|
403
|
+
dispose: queue.dispose,
|
|
380
404
|
};
|
|
381
405
|
};
|
|
@@ -63,8 +63,9 @@ import { warnInDev } from "../utils/warnInDev";
|
|
|
63
63
|
* @globalSlot LIVE — every app-open overwrites this with that open's id, so a reader that captures
|
|
64
64
|
* it into a module-local (or a `const` taken once at mount) posts the PREVIOUS open's session to a
|
|
65
65
|
* server that has already moved on. Read it at the moment of use, through `getCurrentSessionId()` /
|
|
66
|
-
* `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()`
|
|
67
|
-
*
|
|
66
|
+
* `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()` derives its own pinned value
|
|
67
|
+
* from this slot: it holds one sample for the duration of an app-open and re-pins when a genuinely
|
|
68
|
+
* NEW open is registered here, so it follows this slot deliberately and late, never eagerly.
|
|
68
69
|
*/
|
|
69
70
|
const CURRENT_SESSION_ID_SLOT: unique symbol = Symbol.for(
|
|
70
71
|
"@wireai/activation:currentSessionId",
|
|
@@ -73,6 +73,19 @@ export type EventQueue = {
|
|
|
73
73
|
notifyOnline(): void;
|
|
74
74
|
/** Current pending (in-memory) count. */
|
|
75
75
|
size(): number;
|
|
76
|
+
/**
|
|
77
|
+
* Tear this queue down when its owner goes away (a hook's effect cleanup, a host disposing its
|
|
78
|
+
* own instance). Idempotent, never throws, and REQUIRED for any queue that can be re-created:
|
|
79
|
+
* a remount builds a second queue while the first is still holding the storage claim and a live
|
|
80
|
+
* backoff timer, and the two then fight over one persisted slot.
|
|
81
|
+
*
|
|
82
|
+
* It clears the retry timer, releases the claimed storage key so a replacement gets the SAME slot
|
|
83
|
+
* instead of a rotated `…#2` nobody reads next launch, and makes the queue inert — `enqueue`,
|
|
84
|
+
* `flush` and every write become no-ops, so a timer that already fired cannot `removeItem` the
|
|
85
|
+
* slot the live queue owns. Anything still buffered in memory at that point stays on disk under
|
|
86
|
+
* the released key, which is exactly where the replacement queue looks for it.
|
|
87
|
+
*/
|
|
88
|
+
dispose(): void;
|
|
76
89
|
};
|
|
77
90
|
|
|
78
91
|
const DEFAULTS = {
|
|
@@ -146,6 +159,21 @@ const claimQueueKey = (preferred: string, explicit: boolean): string => {
|
|
|
146
159
|
return key;
|
|
147
160
|
};
|
|
148
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Give a claimed key back, so the NEXT queue for the same appId gets the real slot instead of a
|
|
164
|
+
* rotated one. Called only from {@link EventQueue.dispose}.
|
|
165
|
+
*
|
|
166
|
+
* WHY THIS HAS TO EXIST: the claim was write-only, and "already claimed" was read as "held by a LIVE
|
|
167
|
+
* one" — liveness nothing ever tracked. A remount (nav, Fast Refresh, StrictMode's double-invoke, an
|
|
168
|
+
* appId that arrives async) therefore rotated the replacement onto `…#2`, and from then on the whole
|
|
169
|
+
* launch persisted into a slot the next launch never reads: it claims the base key, finds the older
|
|
170
|
+
* blob, and the `#N` ones accumulate forever. Releasing on teardown makes the claim mean what its
|
|
171
|
+
* warning already claimed it meant.
|
|
172
|
+
*/
|
|
173
|
+
const releaseQueueKey = (key: string): void => {
|
|
174
|
+
claimedQueueKeys().delete(key);
|
|
175
|
+
};
|
|
176
|
+
|
|
149
177
|
/** Test-only: forget every claimed queue key. A real RELAUNCH is a new process, so a test that
|
|
150
178
|
* simulates one in-process must call this or its second queue reads as a concurrent sibling.
|
|
151
179
|
* Exported from `@wireai/activation/analytics`, matching `resetAutoDeviceKeys` / `resetCurrentSessionId`. */
|
|
@@ -231,13 +259,23 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
231
259
|
let flushing = false;
|
|
232
260
|
let attempt = 0;
|
|
233
261
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
262
|
+
// Set by `dispose()`. Every write path and the drain check it, because a queue whose owner is
|
|
263
|
+
// gone must not touch a storage slot a replacement queue now owns.
|
|
264
|
+
let disposed = false;
|
|
265
|
+
// The persisted blob may exist and has NOT been read yet. While this is true every write is
|
|
266
|
+
// suppressed: the only thing the queue could write is a view of `pending` that does not include
|
|
267
|
+
// the backlog it has not seen, and `setItem`/`removeItem` would destroy it. Cleared as soon as the
|
|
268
|
+
// read settles, whichever way it settles, so a read that ultimately fails resumes normal
|
|
269
|
+
// persistence rather than suppressing it for the life of the process.
|
|
270
|
+
//
|
|
271
|
+
// ARMED AT CONSTRUCTION, not when the 1500ms timeout fires. It used to be set ONLY in the timeout
|
|
272
|
+
// branch, so for the first 1500ms of every launch the comment above was false and the queue wrote
|
|
273
|
+
// freely over a blob it had not read. The sharp form is not the overwrite but the DELETE: a queue
|
|
274
|
+
// that drains to empty inside that window calls `removeItem` on the slot, and an adapter that
|
|
275
|
+
// resolves a read against its current state (a bridge that queues operations and runs them in
|
|
276
|
+
// order — not every adapter snapshots at call time) then answers `null`. The whole backlog is
|
|
277
|
+
// gone, with no error anywhere.
|
|
278
|
+
let backlogUnread = storage !== undefined;
|
|
241
279
|
|
|
242
280
|
const resolveEnvelope = (): ContextEnvelope | undefined => {
|
|
243
281
|
try {
|
|
@@ -275,6 +313,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
275
313
|
|
|
276
314
|
const persist = (): void => {
|
|
277
315
|
if (!storage) return;
|
|
316
|
+
// A disposed queue no longer owns this slot — a replacement may. See `dispose`.
|
|
317
|
+
if (disposed) return;
|
|
278
318
|
// NEVER write over a blob that has not been read yet. See `backlogUnread`.
|
|
279
319
|
if (backlogUnread) return;
|
|
280
320
|
try {
|
|
@@ -289,9 +329,36 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
289
329
|
}
|
|
290
330
|
};
|
|
291
331
|
|
|
332
|
+
/**
|
|
333
|
+
* The two lifecycle events are the funnel's DENOMINATOR: the server computes `min_sessions` by
|
|
334
|
+
* counting distinct `app.session_started` grouped by `device_key`, and `app.first_open` is the top
|
|
335
|
+
* of the activation funnel. Losing one is not "an event fewer", it is an app-open that never
|
|
336
|
+
* happened as far as every rule reading them is concerned.
|
|
337
|
+
*/
|
|
338
|
+
const isCountedOpenEvent = (event: ClientEvent): boolean =>
|
|
339
|
+
typeof event.question_key === "string" && event.question_key.startsWith("app.");
|
|
340
|
+
|
|
292
341
|
const enforceSizeCap = (): void => {
|
|
293
|
-
|
|
294
|
-
if (
|
|
342
|
+
let overflow = pending.length - maxSize;
|
|
343
|
+
if (overflow <= 0) return;
|
|
344
|
+
// Drop the OLDEST first so the newest events are never the ones lost under pressure — but spend
|
|
345
|
+
// ORDINARY events first, because at an app-open the lifecycle pair IS the oldest thing in the
|
|
346
|
+
// buffer. A host on the documented shared-sink wiring that goes offline for a long session
|
|
347
|
+
// pushes past the cap on screen views alone, and pure drop-oldest evicted `app.first_open` +
|
|
348
|
+
// `app.session_started` before anything else — deleting the open from the funnel while 200
|
|
349
|
+
// screen views survived.
|
|
350
|
+
const kept: QueuedItem[] = [];
|
|
351
|
+
for (const item of pending) {
|
|
352
|
+
if (overflow > 0 && !isCountedOpenEvent(item.event)) {
|
|
353
|
+
overflow--;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
kept.push(item);
|
|
357
|
+
}
|
|
358
|
+
// Only when the buffer is NOTHING BUT counted events does the cap fall on them, oldest first.
|
|
359
|
+
// The cap is a hard ceiling: protecting a class must never turn it into an unbounded buffer.
|
|
360
|
+
if (overflow > 0) kept.splice(0, overflow);
|
|
361
|
+
pending = kept;
|
|
295
362
|
};
|
|
296
363
|
|
|
297
364
|
const safeSig = (event: ClientEvent): string => {
|
|
@@ -366,9 +433,18 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
366
433
|
});
|
|
367
434
|
return;
|
|
368
435
|
}
|
|
436
|
+
backlogUnread = false;
|
|
369
437
|
mergePersisted(parsePersisted(raced));
|
|
438
|
+
// Persist explicitly: writes were suppressed for the whole read window, and `mergePersisted`
|
|
439
|
+
// returns early (without persisting) when the backlog was empty — so without this an event
|
|
440
|
+
// enqueued during the window would sit in memory undurable until the next enqueue.
|
|
441
|
+
persist();
|
|
370
442
|
} catch {
|
|
371
|
-
// Unreadable backlog → start empty; nothing enqueued in-memory is lost.
|
|
443
|
+
// Unreadable backlog → start empty; nothing enqueued in-memory is lost. Writes must resume,
|
|
444
|
+
// or the suppression that protected the unread blob would outlive the read for the whole
|
|
445
|
+
// process and nothing would ever persist again.
|
|
446
|
+
backlogUnread = false;
|
|
447
|
+
persist();
|
|
372
448
|
}
|
|
373
449
|
})();
|
|
374
450
|
|
|
@@ -417,11 +493,15 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
417
493
|
};
|
|
418
494
|
|
|
419
495
|
const drain = async (): Promise<void> => {
|
|
496
|
+
if (disposed) return;
|
|
420
497
|
try {
|
|
421
498
|
await loadPromise;
|
|
422
499
|
} catch {
|
|
423
500
|
// load already swallows; guard the await defensively.
|
|
424
501
|
}
|
|
502
|
+
// Re-checked AFTER the await: the owner can go away while the cold-start read is in flight, and
|
|
503
|
+
// a drain that resumes then would send and dequeue under a key another queue now owns.
|
|
504
|
+
if (disposed) return;
|
|
425
505
|
if (flushing) return;
|
|
426
506
|
flushing = true;
|
|
427
507
|
try {
|
|
@@ -453,6 +533,9 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
453
533
|
};
|
|
454
534
|
|
|
455
535
|
const enqueue = (event: ClientEvent): void => {
|
|
536
|
+
// A disposed queue is inert: it cannot persist (the slot belongs to its replacement) and must
|
|
537
|
+
// not send, so buffering here would only pretend to accept the event.
|
|
538
|
+
if (disposed) return;
|
|
456
539
|
try {
|
|
457
540
|
const stamped = stamp(event);
|
|
458
541
|
const sig = safeSig(stamped);
|
|
@@ -479,5 +562,17 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
479
562
|
|
|
480
563
|
const size = (): number => pending.length;
|
|
481
564
|
|
|
482
|
-
|
|
565
|
+
const dispose = (): void => {
|
|
566
|
+
if (disposed) return;
|
|
567
|
+
disposed = true;
|
|
568
|
+
// The retry is the dangerous half: `unrefTimer` is a Node-only affordance (Hermes has no
|
|
569
|
+
// `unref`), so on a device the backoff timer of an unmounted queue is fully alive, up to six
|
|
570
|
+
// attempts and a 30s ceiling. Left running it wakes up, drains, empties, and `removeItem`s a
|
|
571
|
+
// slot the live queue is using.
|
|
572
|
+
clearRetry();
|
|
573
|
+
// Only claimed when there IS storage (see the claim above), so only released then.
|
|
574
|
+
if (storage) releaseQueueKey(key);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
return { enqueue, flush, notifyOnline, size, dispose };
|
|
483
578
|
};
|
|
@@ -341,22 +341,46 @@ export const reportClientEvent = (
|
|
|
341
341
|
export const reportClientEventsAwait = async (
|
|
342
342
|
target: ClientEventTarget | undefined,
|
|
343
343
|
events: ClientEvent[],
|
|
344
|
-
): Promise<boolean> =>
|
|
344
|
+
): Promise<boolean> => (await reportClientEventsOutcome(target, events)) === "delivered";
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* WHY a failed send is not one thing. `false` collapses two situations that call for OPPOSITE
|
|
348
|
+
* responses, and a caller that wants to make the event durable has to tell them apart:
|
|
349
|
+
*
|
|
350
|
+
* • `unreachable` — the request never got an answer (no `fetch`, a network error, a non-2xx).
|
|
351
|
+
* Nothing is wrong with the EVENT. Retrying later is exactly right, and is what every
|
|
352
|
+
* offline-first path in this kit does.
|
|
353
|
+
* • `refused` — the server READ the event and threw it away (HTTP 200 with `skipped > 0`,
|
|
354
|
+
* a per-event validation failure), or there is no transport to build a request from at all.
|
|
355
|
+
* Retrying cannot change the answer: buffering here would park a poison event in a PERSISTED
|
|
356
|
+
* backlog that re-sends and is re-refused on every drain and every launch, forever.
|
|
357
|
+
*
|
|
358
|
+
* The boolean sibling above is this function with the two collapsed back together, so there is one
|
|
359
|
+
* implementation and the two can never drift.
|
|
360
|
+
*/
|
|
361
|
+
export type SendOutcome = "delivered" | "refused" | "unreachable";
|
|
362
|
+
|
|
363
|
+
export const reportClientEventsOutcome = async (
|
|
364
|
+
target: ClientEventTarget | undefined,
|
|
365
|
+
events: ClientEvent[],
|
|
366
|
+
): Promise<SendOutcome> => {
|
|
345
367
|
try {
|
|
346
368
|
const req = buildEventsRequest(target, events);
|
|
347
|
-
|
|
369
|
+
// No target / nothing serializable: there is no endpoint to retry against.
|
|
370
|
+
if (!req) return "refused";
|
|
348
371
|
const res = await fetch(req.url, req.init);
|
|
349
|
-
if (!res || !res.ok) return
|
|
372
|
+
if (!res || !res.ok) return "unreachable";
|
|
350
373
|
// ONE body read, used for both the verdict and the dev warning — it can only be read once.
|
|
351
374
|
const ack = await readEventsAck(res);
|
|
352
|
-
if (!ack || ack.skipped <= 0) return
|
|
375
|
+
if (!ack || ack.skipped <= 0) return "delivered";
|
|
353
376
|
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
354
377
|
console.warn(describeDiscarded(ack));
|
|
355
378
|
}
|
|
356
|
-
|
|
379
|
+
// The server answered and declined THIS event. A retry is a re-decline.
|
|
380
|
+
return "refused";
|
|
357
381
|
} catch {
|
|
358
|
-
// Unreachable / missing-fetch / network — best-effort,
|
|
359
|
-
return
|
|
382
|
+
// Unreachable / missing-fetch / network — best-effort, and retryable.
|
|
383
|
+
return "unreachable";
|
|
360
384
|
}
|
|
361
385
|
};
|
|
362
386
|
|
|
@@ -36,6 +36,11 @@ export const useAnalytics = (
|
|
|
36
36
|
const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
|
|
37
37
|
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
38
38
|
prevKeys.current = currentKeys;
|
|
39
|
+
// The instance being REPLACED (credentials changed, or an `appId` that arrived async) owns a
|
|
40
|
+
// claimed storage slot and a live backoff timer. Hand them back before building the replacement,
|
|
41
|
+
// or the new instance rotates onto a `…#2` key that no later launch reads, and the old one's
|
|
42
|
+
// retry can still wake up and `removeItem` the slot the new one is using.
|
|
43
|
+
ref.current?.dispose();
|
|
39
44
|
ref.current = createAnalytics(config, options);
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -59,5 +64,17 @@ export const useAnalytics = (
|
|
|
59
64
|
if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
|
|
60
65
|
}, [hostDeviceKey]);
|
|
61
66
|
|
|
67
|
+
// UNMOUNT: give the storage claim and the retry timer back. A screen that mounts this hook is
|
|
68
|
+
// built and torn down repeatedly (navigation, Fast Refresh, StrictMode's double-invoke), and each
|
|
69
|
+
// rebuild used to leave the previous queue holding the appId's slot — so every remount after the
|
|
70
|
+
// first persisted into `…#2`, `…#3`, and the next launch read none of them.
|
|
71
|
+
useEffect(
|
|
72
|
+
() => () => {
|
|
73
|
+
ref.current?.dispose();
|
|
74
|
+
ref.current = undefined;
|
|
75
|
+
},
|
|
76
|
+
[],
|
|
77
|
+
);
|
|
78
|
+
|
|
62
79
|
return ref.current;
|
|
63
80
|
};
|
package/src/context/deviceId.ts
CHANGED
|
@@ -64,9 +64,10 @@ export const mintDeviceId = (): string => {
|
|
|
64
64
|
// RESIDUAL WINDOW: the storage read is async, so an event emitted in the milliseconds before
|
|
65
65
|
// hydration completes carries the freshly minted id rather than the persisted one. The registry makes
|
|
66
66
|
// every surface agree on WHICH id that is; it does not make the read sync. A caller that can afford to
|
|
67
|
-
// wait (
|
|
68
|
-
// events are the ONLY ones the server counts `min_sessions` from, so a
|
|
69
|
-
// millisecond of noise, it is a counter that can never exceed 1.
|
|
67
|
+
// wait (`useLifecycleEvents` / `useSessionStart`, both app-open paths — see `hydrateDeviceIdentity`)
|
|
68
|
+
// must await instead: their events are the ONLY ones the server counts `min_sessions` from, so a
|
|
69
|
+
// per-launch id there is not a millisecond of noise, it is a counter that can never exceed 1. Waiting
|
|
70
|
+
// is only half of it — the settled outcome can still be NON-DURABLE, and those callers refuse it.
|
|
70
71
|
|
|
71
72
|
/**
|
|
72
73
|
* Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
|
|
@@ -238,6 +239,12 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
|
|
|
238
239
|
*
|
|
239
240
|
* Never throws or rejects: a missing, hung, or rejecting adapter resolves to the in-memory id, and
|
|
240
241
|
* with no `storage` it resolves immediately (there is nothing to hydrate from).
|
|
242
|
+
*
|
|
243
|
+
* ⚠️ IT RETURNS A BARE STRING, so it CANNOT say whether the id survives the launch — a degraded
|
|
244
|
+
* adapter resolves to the in-memory mint and reads identically to a persisted one. No kit surface
|
|
245
|
+
* uses it any more (0.14.0 moved the two lifecycle hooks off it): a caller writing a cross-launch
|
|
246
|
+
* join key wants {@link hydrateDeviceIdentity} and its `durable` flag. Kept as public API for a host
|
|
247
|
+
* that only wants "the id", never as the way to decide whether to stamp one.
|
|
241
248
|
*/
|
|
242
249
|
export const hydrateAutoDeviceKey = async (
|
|
243
250
|
opts: ResolveAutoDeviceKeyOptions = {},
|