@wireai/activation 0.13.6 → 0.14.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/AGENTS.md +21 -9
- package/CHANGELOG.md +172 -1
- package/INTEGRATION_PROMPT.md +7 -4
- package/README.md +10 -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 +24 -6
- package/dist/index.d.ts +24 -6
- package/dist/index.js +631 -548
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +631 -548
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js.map +1 -1
- 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 +4 -3
- package/src/activation/useWireActivation.ts +14 -1
- package/src/activation/wireActivation.ts +68 -2
- package/src/analytics/analyticsFacade.ts +24 -0
- 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 +12 -1
- 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
|
@@ -52,16 +52,29 @@ export type LoadedSession = {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* The verdict of a read that RAN OUT OF TIME. A distinct value, and deliberately never `undefined`:
|
|
56
|
+
* an `undefined` here is byte-identical to "the adapter answered, there is nothing stored", and that
|
|
57
|
+
* conflation is what let a timed-out read mint a fresh session id and WRITE IT OVER the still-valid
|
|
58
|
+
* one it had not managed to read yet. The event queue reached the same conclusion independently
|
|
59
|
+
* (`analytics/eventQueue.ts`); this is the same sentinel for the same reason.
|
|
60
|
+
*
|
|
61
|
+
* "Did not answer in time" is not "there is nothing there", and the difference is the difference
|
|
62
|
+
* between starting clean and destroying a session mid-onboarding.
|
|
63
|
+
*/
|
|
64
|
+
export const READ_TIMED_OUT: unique symbol = Symbol("wireai:storage-read-timeout");
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Race a storage read against a ceiling, resolving {@link READ_TIMED_OUT} when the adapter did not
|
|
68
|
+
* answer in time.
|
|
56
69
|
* Exported so the permission memory reads through the same ceiling as the session seed rather than
|
|
57
70
|
* carrying a second copy of it. NOT the kit's only such helper: `features/cache.ts`,
|
|
58
71
|
* `session-analytics/lifecycle.ts` and `analytics/eventQueue.ts` each keep their own local timeout
|
|
59
72
|
* for their own transports. Consolidating those is a separate change, not this one.
|
|
60
73
|
*/
|
|
61
|
-
export const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T |
|
|
74
|
+
export const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
|
|
62
75
|
let timer: ReturnType<typeof setTimeout>;
|
|
63
|
-
const timeout = new Promise<
|
|
64
|
-
timer = setTimeout(() => resolve(
|
|
76
|
+
const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
|
|
77
|
+
timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
|
|
65
78
|
});
|
|
66
79
|
// Clear the timer once the race settles, so a won read doesn't leave a ≤ms no-op
|
|
67
80
|
// timer holding the closure alive.
|
|
@@ -96,12 +109,19 @@ export const loadPersistedSession = async (
|
|
|
96
109
|
key: string,
|
|
97
110
|
ttlMs: number = DEFAULT_SESSION_TTL_MS,
|
|
98
111
|
): Promise<LoadedSession> => {
|
|
99
|
-
let
|
|
112
|
+
let raw: string | null | typeof READ_TIMED_OUT;
|
|
100
113
|
try {
|
|
101
|
-
|
|
114
|
+
raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
102
115
|
} catch {
|
|
103
|
-
|
|
116
|
+
raw = null;
|
|
104
117
|
}
|
|
118
|
+
// TIMED OUT: the mount cannot wait, so it still gets an id and starts clean — but it must NOT
|
|
119
|
+
// persist it. Writing here overwrites a session the read was still fetching, and a valid
|
|
120
|
+
// mid-onboarding session id is exactly the thing whose loss the server reads as a phantom drop.
|
|
121
|
+
// Losing persistence for ONE launch is recoverable; overwriting the blob is not.
|
|
122
|
+
if (raw === READ_TIMED_OUT) return { id: makeSessionId(), resumed: false };
|
|
123
|
+
|
|
124
|
+
const stored = parsePersisted(raw);
|
|
105
125
|
if (stored && Date.now() - stored.ts < ttlMs) {
|
|
106
126
|
return { id: stored.id, resumed: true };
|
|
107
127
|
}
|
|
@@ -121,7 +141,11 @@ export const peekPersistedSession = async (
|
|
|
121
141
|
key: string,
|
|
122
142
|
): Promise<{ id: string; ts: number } | undefined> => {
|
|
123
143
|
try {
|
|
124
|
-
|
|
144
|
+
const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
145
|
+
// A timed-out read is `undefined` here exactly as before: this function only READS, so
|
|
146
|
+
// "unknown" and "absent" lead to the same caller behaviour and nothing destructive follows.
|
|
147
|
+
if (raw === READ_TIMED_OUT) return undefined;
|
|
148
|
+
return parsePersisted(raw);
|
|
125
149
|
} catch {
|
|
126
150
|
return undefined;
|
|
127
151
|
}
|
|
@@ -51,10 +51,18 @@ export const firstOpenStorageKey = (appId: string): string => `wireai:first_open
|
|
|
51
51
|
/** Ceiling on the flag read — a hung adapter degrades to the in-memory latch, never a stuck gate. */
|
|
52
52
|
const READ_TIMEOUT_MS = 1_500;
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The verdict of a flag read that ran out of time. DISTINCT from `null`/`undefined`, because on this
|
|
56
|
+
* particular read those mean "this install has never fired its first open" — and acting on that
|
|
57
|
+
* belief is an irreversible emit. Same sentinel, same reasoning, as `session/persistedSession.ts`
|
|
58
|
+
* and `analytics/eventQueue.ts`.
|
|
59
|
+
*/
|
|
60
|
+
const READ_TIMED_OUT: unique symbol = Symbol("wireai:first-open-read-timeout");
|
|
61
|
+
|
|
62
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
|
|
55
63
|
let timer: ReturnType<typeof setTimeout>;
|
|
56
|
-
const timeout = new Promise<
|
|
57
|
-
timer = setTimeout(() => resolve(
|
|
64
|
+
const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
|
|
65
|
+
timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
|
|
58
66
|
});
|
|
59
67
|
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
60
68
|
};
|
|
@@ -185,6 +193,12 @@ export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
|
185
193
|
void (async () => {
|
|
186
194
|
try {
|
|
187
195
|
const seen = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
196
|
+
// TIMED OUT: the flag may well say "already fired" — the read is still in flight, it just blew
|
|
197
|
+
// the ceiling. Emitting on that guess turns once-EVER into once-per-slow-launch, and the
|
|
198
|
+
// top-of-funnel count inflates on exactly the devices (cold start, slow storage) that are
|
|
199
|
+
// slowest. Declining costs at most one launch's first_open on a genuinely first install, and
|
|
200
|
+
// the next launch retries with a read that lands. See the sentinel above.
|
|
201
|
+
if (seen === READ_TIMED_OUT) return;
|
|
188
202
|
// A prior launch already fired + wrote the flag → once-ever satisfied, no-op.
|
|
189
203
|
if (seen) return;
|
|
190
204
|
emitFirstOpen(opts);
|
|
@@ -195,8 +209,15 @@ export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
|
195
209
|
// Missing/broken storage write — swallow.
|
|
196
210
|
}
|
|
197
211
|
} catch {
|
|
198
|
-
//
|
|
199
|
-
|
|
212
|
+
// UNREADABLE (a locked / full / permission-denied adapter): the same question with a worse
|
|
213
|
+
// answer, because it fails on every launch. This used to emit, which meant `app.first_open`
|
|
214
|
+
// fired on EVERY launch of that install for as long as the adapter stayed broken — the top of
|
|
215
|
+
// the funnel counting one device as an unbounded number of installs. The kit's standing rule
|
|
216
|
+
// for an unverifiable persistence answer (0.13.0 `autoJoinKey`, 0.14.0 lifecycle device keys)
|
|
217
|
+
// is to decline rather than to emit something that corrupts the metric. Same verdict here.
|
|
218
|
+
//
|
|
219
|
+
// A host that wants a first_open on a device with no working storage should pass no `storage`
|
|
220
|
+
// at all: that is the documented degraded mode and it still fires once per process.
|
|
200
221
|
}
|
|
201
222
|
})();
|
|
202
223
|
};
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { setCurrentSessionId } from "../analytics/currentSession";
|
|
35
35
|
import {
|
|
36
|
+
buildEventsRequest,
|
|
36
37
|
makeSessionId,
|
|
37
38
|
warnOnSkippedEvents,
|
|
38
39
|
type ClientEvent,
|
|
@@ -158,16 +159,19 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
158
159
|
return;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
// Fallback: this emitter's own direct POST when no sink is wired
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
// Fallback: this emitter's own direct POST when no sink is wired — routed through the ONE
|
|
163
|
+
// canonical `/v1/events` builder (url + headers + body + the epoch-ms → ISO8601 `ts`
|
|
164
|
+
// conversion), exactly as `lifecycle.ts` already does with its own fallback.
|
|
165
|
+
//
|
|
166
|
+
// It used to hand-build the request here. That was harmless as long as this event carried no
|
|
167
|
+
// field needing wire conversion, but it made this the ONE send path of five that skipped the
|
|
168
|
+
// converter — on the event `min_sessions` is counted from, where the failure shape is a 200 with
|
|
169
|
+
// `{written: 0, skipped: N}` and no client-visible error at all (the 0.13.0 `ts` defect). The
|
|
170
|
+
// choke-point comment in `reportClientEvent.ts` claimed five paths and delivered four;
|
|
171
|
+
// `sendPathChokePoint.test.ts` now holds the claim to the code.
|
|
172
|
+
const req = buildEventsRequest(target, [event as unknown as ClientEvent]);
|
|
173
|
+
if (!req) return;
|
|
174
|
+
void fetch(req.url, req.init)
|
|
171
175
|
.then((res) => {
|
|
172
176
|
// A 200 can still carry `skipped:N` — the server took the request and threw the event away.
|
|
173
177
|
// Log-only; this path has nothing to retry either way.
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* Firing moments mirror {@link useSessionStart} exactly:
|
|
12
12
|
* • ON MOUNT — the app opened (cold start / provider first render). `app.first_open` fires here
|
|
13
13
|
* too (once ever, gated by the persisted flag in `reportFirstOpen`). When the kit's AUTO device
|
|
14
|
-
* key is the one in play (no host `deviceKey`, `config.storage` present) the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* key is the one in play (no host `deviceKey`, `config.storage` present) the fire waits on one
|
|
15
|
+
* storage read so both events carry the PERSISTED key, not a freshly minted one — and if that
|
|
16
|
+
* read settles NON-DURABLE the key is refused outright rather than degraded to the per-launch
|
|
17
|
+
* mint. See the `hydrateDeviceIdentity` note below.
|
|
17
18
|
* • ON FOREGROUND after a real background of at least {@link BACKGROUND_SESSION_MS} (30 min) — a
|
|
18
19
|
* new app-open, so a fresh `app.session_started` fires. A quick app-switch does NOT count.
|
|
19
20
|
*
|
|
@@ -32,7 +33,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
|
32
33
|
|
|
33
34
|
import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
|
|
34
35
|
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
35
|
-
import {
|
|
36
|
+
import { hydrateDeviceIdentity } from "../context/deviceId";
|
|
36
37
|
import { resolveIdentity } from "../identity/identityRecord";
|
|
37
38
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
38
39
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
@@ -50,8 +51,10 @@ export interface LifecycleConfig {
|
|
|
50
51
|
appVersion?: string;
|
|
51
52
|
/** Tenant/app id — namespaces the first-open flag AND the hook's internal queue storage key. */
|
|
52
53
|
appId?: string;
|
|
53
|
-
/** Host storage (AsyncStorage subset). Enables the persisted once-ever first-open flag
|
|
54
|
-
* offline durability of the hook's internal queue
|
|
54
|
+
/** Host storage (AsyncStorage subset). Enables the persisted once-ever first-open flag, the
|
|
55
|
+
* offline durability of the hook's internal queue, AND the auto `device_key` fallback — the last
|
|
56
|
+
* one only when the adapter actually persists (one that throws or rejects gets no fallback, the
|
|
57
|
+
* same verdict as no storage at all). Omit it and all three degrade to in-memory. */
|
|
55
58
|
storage?: WireOnboardingStorage;
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -98,6 +101,10 @@ export const useLifecycleEvents = (
|
|
|
98
101
|
const queueRef = useRef<EventQueue | undefined>(undefined);
|
|
99
102
|
|
|
100
103
|
useEffect(() => {
|
|
104
|
+
// Set by the cleanup below: an in-flight storage read must not fire an app-open for a mount
|
|
105
|
+
// that is already gone. Declared first because every async fire path closes over it.
|
|
106
|
+
let cancelled = false;
|
|
107
|
+
|
|
101
108
|
const resolveSink = (): ((event: ClientEvent) => void) | undefined => {
|
|
102
109
|
const { config: cfg, options: opts } = latest.current;
|
|
103
110
|
// Host-owned shared queue wins — one queue across the whole kit.
|
|
@@ -130,13 +137,17 @@ export const useLifecycleEvents = (
|
|
|
130
137
|
* one. Two disjoint identity spaces again: the counter the rule reads could never increase, so
|
|
131
138
|
* `min_sessions` was structurally unsatisfiable and the gate never fired from the server side.
|
|
132
139
|
*
|
|
133
|
-
* ONLY WITH `storage
|
|
134
|
-
* storage
|
|
135
|
-
*
|
|
140
|
+
* ONLY WITH `storage` THAT ACTUALLY WORKED: the auto id is per-INSTALL only when it can be
|
|
141
|
+
* persisted. With no storage — or with an adapter that threw / rejected — it is per-LAUNCH,
|
|
142
|
+
* which makes every open look like a brand-new device and corrupts `min_sessions` in the other
|
|
143
|
+
* direction (while inflating distinct-device counts on top). So no storage → no fallback, and a
|
|
144
|
+
* non-durable read → no fallback either: `openAutoDeviceKey` below hands this function
|
|
145
|
+
* `undefined` in both cases, and it never reaches for the synchronous mint itself.
|
|
136
146
|
*/
|
|
137
147
|
const resolveDeviceKey = (
|
|
138
148
|
cfg: LifecycleConfig | undefined,
|
|
139
149
|
opts: UseLifecycleEventsOptions,
|
|
150
|
+
autoDeviceKey: string | undefined,
|
|
140
151
|
): string | undefined => {
|
|
141
152
|
// A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
|
|
142
153
|
// mount that was not given one can tell "this app owns no device id" from "this app owns one
|
|
@@ -148,8 +159,33 @@ export const useLifecycleEvents = (
|
|
|
148
159
|
scope: cfg?.appId,
|
|
149
160
|
})?.value;
|
|
150
161
|
if (host) return host;
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
return autoDeviceKey;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the auto `device_key` for ONE app-open and then fire it, so EVERY fire path (mount and
|
|
167
|
+
* foreground re-fire alike) goes through the same vetting.
|
|
168
|
+
*
|
|
169
|
+
* A host-supplied key or a config with no `storage` fires SYNCHRONOUSLY — there is nothing to
|
|
170
|
+
* read. Otherwise the fire waits on `hydrateDeviceIdentity`, the provenance-carrying read, and
|
|
171
|
+
* honours the rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a
|
|
172
|
+
* key onto the wire as a cross-launch join must read `durable` and refuse a `false`."* A refusal
|
|
173
|
+
* emits the event with NO auto key — the same verdict `<WireOnboarding>` reaches on a broken
|
|
174
|
+
* adapter, and strictly better than a key that differs on every launch. Never rejects: the read
|
|
175
|
+
* resolves to a record or `undefined`, never a throw.
|
|
176
|
+
*/
|
|
177
|
+
const openAutoDeviceKey = (fire: (autoDeviceKey: string | undefined) => void): void => {
|
|
178
|
+
const { config: cfg, options: opts } = latest.current;
|
|
179
|
+
const hostKey =
|
|
180
|
+
typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
|
|
181
|
+
if (hostKey || !cfg?.storage) {
|
|
182
|
+
fire(undefined);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
|
|
186
|
+
if (cancelled) return;
|
|
187
|
+
fire(identity?.durable ? identity.value : undefined);
|
|
188
|
+
});
|
|
153
189
|
};
|
|
154
190
|
|
|
155
191
|
// ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
|
|
@@ -164,7 +200,7 @@ export const useLifecycleEvents = (
|
|
|
164
200
|
// session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
|
|
165
201
|
// Accepts the per-open id so the mount open reuses `mountOpenSessionId`; omit it and the emitter
|
|
166
202
|
// mints a fresh id (a genuinely new open). Either way the once-guard dedupes within the open.
|
|
167
|
-
const fireSession = (sessionId
|
|
203
|
+
const fireSession = (sessionId: string | undefined, autoDeviceKey: string | undefined) => {
|
|
168
204
|
const { config: cfg, options: opts } = latest.current;
|
|
169
205
|
if (opts.enabled === false) return;
|
|
170
206
|
if (!cfg?.serverUrl && !opts.sink) return;
|
|
@@ -176,7 +212,7 @@ export const useLifecycleEvents = (
|
|
|
176
212
|
sink: resolveSink(),
|
|
177
213
|
sessionId,
|
|
178
214
|
userId: opts.userId,
|
|
179
|
-
deviceKey: resolveDeviceKey(cfg, opts),
|
|
215
|
+
deviceKey: resolveDeviceKey(cfg, opts, autoDeviceKey),
|
|
180
216
|
sessionCount: opts.sessionCount,
|
|
181
217
|
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
182
218
|
platform: Platform.OS,
|
|
@@ -189,8 +225,8 @@ export const useLifecycleEvents = (
|
|
|
189
225
|
// `session_started` for `mountOpenSessionId` before first_open references the same id; then
|
|
190
226
|
// first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to the
|
|
191
227
|
// SAME per-open id so it is never a phantom session.
|
|
192
|
-
const fireMountOpen = () => {
|
|
193
|
-
fireSession(mountOpenSessionId);
|
|
228
|
+
const fireMountOpen = (autoDeviceKey: string | undefined) => {
|
|
229
|
+
fireSession(mountOpenSessionId, autoDeviceKey);
|
|
194
230
|
|
|
195
231
|
const { config: cfg, options: opts } = latest.current;
|
|
196
232
|
if (opts.enabled === false) return;
|
|
@@ -204,7 +240,7 @@ export const useLifecycleEvents = (
|
|
|
204
240
|
storage: cfg?.storage,
|
|
205
241
|
appId: cfg?.appId,
|
|
206
242
|
userId: opts.userId,
|
|
207
|
-
deviceKey: resolveDeviceKey(cfg, opts),
|
|
243
|
+
deviceKey: resolveDeviceKey(cfg, opts, autoDeviceKey),
|
|
208
244
|
sessionCount: opts.sessionCount,
|
|
209
245
|
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
210
246
|
platform: Platform.OS,
|
|
@@ -223,21 +259,12 @@ export const useLifecycleEvents = (
|
|
|
223
259
|
// 1 — and `first_open`, fired once ever, ended up under a key no later event shares, which breaks
|
|
224
260
|
// `first_open` → `activated` cohorting too. One storage read at mount buys both back.
|
|
225
261
|
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
? mountOpts.deviceKey
|
|
233
|
-
: undefined;
|
|
234
|
-
if (!hostKey && mountCfg?.storage) {
|
|
235
|
-
void hydrateAutoDeviceKey({ appId: mountCfg.appId, storage: mountCfg.storage }).then(() => {
|
|
236
|
-
if (!cancelled) fireMountOpen();
|
|
237
|
-
});
|
|
238
|
-
} else {
|
|
239
|
-
fireMountOpen();
|
|
240
|
-
}
|
|
262
|
+
// 0.14.0: awaiting the read was only half of it. The read can settle NON-DURABLE (a locked /
|
|
263
|
+
// full / permission-denied adapter — it throws or rejects and the id stays process-scoped), and
|
|
264
|
+
// the `.then` fired regardless, so a degraded store walked the per-launch key straight back onto
|
|
265
|
+
// the counted events: two launches over a throwing MMKV produced four distinct `wdev_*`. The
|
|
266
|
+
// outcome is now read through `openAutoDeviceKey`, which refuses a `durable: false` id.
|
|
267
|
+
openAutoDeviceKey(fireMountOpen);
|
|
241
268
|
|
|
242
269
|
// Foreground after a real background = a new app-open.
|
|
243
270
|
let backgroundedAt: number | null = null;
|
|
@@ -249,7 +276,11 @@ export const useLifecycleEvents = (
|
|
|
249
276
|
if (state === "active") {
|
|
250
277
|
const since = backgroundedAt;
|
|
251
278
|
backgroundedAt = null;
|
|
252
|
-
|
|
279
|
+
// A new open re-runs the same vetting: the auto key must still be a durable one, and a
|
|
280
|
+
// degraded read that released its latches gets a fresh attempt rather than a cached verdict.
|
|
281
|
+
if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) {
|
|
282
|
+
openAutoDeviceKey((autoDeviceKey) => fireSession(undefined, autoDeviceKey));
|
|
283
|
+
}
|
|
253
284
|
}
|
|
254
285
|
};
|
|
255
286
|
const sub = AppState.addEventListener("change", onChange);
|
|
@@ -258,6 +289,13 @@ export const useLifecycleEvents = (
|
|
|
258
289
|
cancelled = true;
|
|
259
290
|
// RN >= 0.65 returns a subscription with remove(); guard for older shims.
|
|
260
291
|
if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
|
|
292
|
+
// Tear the hook's OWN queue down. This hook re-creates it on every remount against a fixed
|
|
293
|
+
// explicit storage key, so without a teardown the dead queue keeps a live backoff timer over
|
|
294
|
+
// the same slot the replacement now owns: it wakes up, drains, empties, and `removeItem`s the
|
|
295
|
+
// replacement's persisted `app.session_started`. A host-supplied `sink` is NOT touched — that
|
|
296
|
+
// queue belongs to the host and outlives this mount by design.
|
|
297
|
+
queueRef.current?.dispose();
|
|
298
|
+
queueRef.current = undefined;
|
|
261
299
|
};
|
|
262
300
|
// Mount-only effect: firing reads fresh values via `latest`, so no reactive deps.
|
|
263
301
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* useSessionStart — the optional convenience hook that fires {@link reportSessionStart} for you.
|
|
3
3
|
*
|
|
4
4
|
* Two firing moments, mirroring the reference apps' session-counter semantics:
|
|
5
|
-
* • ON MOUNT — the app opened (cold start or the provider first rendered).
|
|
5
|
+
* • ON MOUNT — the app opened (cold start or the provider first rendered). When the kit's AUTO
|
|
6
|
+
* device key is the one in play (no host `deviceKey`, `config.storage` present) the fire waits
|
|
7
|
+
* on one storage read, so the event carries the PERSISTED key rather than a fresh per-launch
|
|
8
|
+
* mint, and it refuses the key outright if that read settles non-durable (0.14.0).
|
|
6
9
|
* • ON FOREGROUND after a real background — when AppState returns to `active` having been
|
|
7
10
|
* backgrounded for at least {@link BACKGROUND_SESSION_MS} (30 min), that's a NEW open, so a
|
|
8
11
|
* fresh session fires. A quick app-switch (under the threshold) does NOT count as a new open.
|
|
@@ -26,7 +29,7 @@ import { useEffect, useRef } from "react";
|
|
|
26
29
|
import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
27
30
|
|
|
28
31
|
import type { ClientEventTarget } from "../analytics/reportClientEvent";
|
|
29
|
-
import {
|
|
32
|
+
import { hydrateDeviceIdentity } from "../context/deviceId";
|
|
30
33
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
31
34
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
32
35
|
import { reportSessionStart } from "./reportSessionStart";
|
|
@@ -44,8 +47,9 @@ export interface SessionStartConfig {
|
|
|
44
47
|
appVersion?: string;
|
|
45
48
|
/** Tenant/app id — namespaces the auto `device_key` fallback below. */
|
|
46
49
|
appId?: string;
|
|
47
|
-
/** Host storage (AsyncStorage subset). Present → `app.session_started`
|
|
48
|
-
* persisted auto `device_key` when the host passes none. Absent
|
|
50
|
+
/** Host storage (AsyncStorage subset). Present AND actually persisting → `app.session_started`
|
|
51
|
+
* falls back to the kit's persisted auto `device_key` when the host passes none. Absent, or an
|
|
52
|
+
* adapter that throws / rejects → no fallback (a per-launch key is worse than none, see below). */
|
|
49
53
|
storage?: WireOnboardingStorage;
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -78,8 +82,13 @@ export const useSessionStart = (
|
|
|
78
82
|
latest.current = { config, options };
|
|
79
83
|
|
|
80
84
|
useEffect(() => {
|
|
85
|
+
// Set by the cleanup below: an in-flight storage read must not fire an app-open for a mount that
|
|
86
|
+
// is already gone. Declared first because the async fire path closes over it.
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
|
|
81
89
|
/**
|
|
82
|
-
* The `device_key` this open rides under. A host-supplied id always wins
|
|
90
|
+
* The `device_key` this open rides under. A host-supplied id always wins; the auto id arrives
|
|
91
|
+
* already vetted from `openAutoDeviceKey` below (a non-durable one never gets here).
|
|
83
92
|
*
|
|
84
93
|
* BACKPORTED FROM `useLifecycleEvents` (which had this and this hook did not): `min_sessions` is
|
|
85
94
|
* computed server-side by counting distinct `app.session_started` grouped by
|
|
@@ -88,21 +97,50 @@ export const useSessionStart = (
|
|
|
88
97
|
* spaces, and a counter that could never increase. The two session-start paths must not diverge
|
|
89
98
|
* on the thing the firing rule reads.
|
|
90
99
|
*
|
|
91
|
-
* ONLY WITH `storage
|
|
92
|
-
*
|
|
93
|
-
* `min_sessions` in the other
|
|
100
|
+
* ONLY WITH `storage` THAT ACTUALLY WORKED, for the same reason as the sibling hook: without
|
|
101
|
+
* persistence — or with an adapter that threw / rejected — the auto id is per-LAUNCH, and a
|
|
102
|
+
* per-launch key makes every open look like a new device, corrupting `min_sessions` in the other
|
|
103
|
+
* direction. Both cases arrive here as `undefined`; this function never mints.
|
|
94
104
|
*/
|
|
95
105
|
const resolveDeviceKey = (
|
|
96
|
-
cfg: SessionStartConfig | undefined,
|
|
97
106
|
opts: UseSessionStartOptions,
|
|
107
|
+
autoDeviceKey: string | undefined,
|
|
98
108
|
): string | undefined => {
|
|
99
109
|
const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
|
|
100
110
|
if (host) return host;
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
return autoDeviceKey;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the auto `device_key` for ONE app-open, then fire — the fix this hook never got.
|
|
116
|
+
*
|
|
117
|
+
* IT USED TO FIRE SYNCHRONOUSLY. `resolveAutoDeviceKey` is synchronous by contract: it returns a
|
|
118
|
+
* freshly minted id and adopts the persisted one a storage read later. So on a perfectly HEALTHY
|
|
119
|
+
* store this hook stamped a brand-new `wdev_*` on `app.session_started` every single launch,
|
|
120
|
+
* while every other kit surface adopted the persisted id milliseconds afterwards — the exact
|
|
121
|
+
* pre-fix behaviour the sibling hook's mount comment describes. `min_sessions` counts distinct
|
|
122
|
+
* opens grouped by that key, so it was structurally incapable of exceeding 1.
|
|
123
|
+
*
|
|
124
|
+
* The auto path now awaits `hydrateDeviceIdentity` (the provenance-carrying read) and honours the
|
|
125
|
+
* rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
|
|
126
|
+
* wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
|
|
127
|
+
* config with no `storage` still fires synchronously — there is nothing to read.
|
|
128
|
+
*/
|
|
129
|
+
const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
|
|
130
|
+
const { config: cfg, options: opts } = latest.current;
|
|
131
|
+
const hostKey =
|
|
132
|
+
typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
|
|
133
|
+
if (hostKey || !cfg?.storage) {
|
|
134
|
+
fireOpen(undefined);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
|
|
138
|
+
if (cancelled) return;
|
|
139
|
+
fireOpen(identity?.durable ? identity.value : undefined);
|
|
140
|
+
});
|
|
103
141
|
};
|
|
104
142
|
|
|
105
|
-
const fire = () => {
|
|
143
|
+
const fire = (autoDeviceKey: string | undefined) => {
|
|
106
144
|
const { config: cfg, options: opts } = latest.current;
|
|
107
145
|
if (!cfg?.serverUrl) return;
|
|
108
146
|
if (opts.enabled === false) return;
|
|
@@ -114,7 +152,7 @@ export const useSessionStart = (
|
|
|
114
152
|
target,
|
|
115
153
|
// A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
|
|
116
154
|
userId: opts.userId,
|
|
117
|
-
deviceKey: resolveDeviceKey(
|
|
155
|
+
deviceKey: resolveDeviceKey(opts, autoDeviceKey),
|
|
118
156
|
sessionCount: opts.sessionCount,
|
|
119
157
|
appVersion: cfg.appVersion ?? device.appVersion,
|
|
120
158
|
platform: Platform.OS,
|
|
@@ -124,7 +162,7 @@ export const useSessionStart = (
|
|
|
124
162
|
};
|
|
125
163
|
|
|
126
164
|
// 1) Mount = an app-open.
|
|
127
|
-
fire
|
|
165
|
+
openAutoDeviceKey(fire);
|
|
128
166
|
|
|
129
167
|
// 2) Foreground after a real background = a new app-open.
|
|
130
168
|
let backgroundedAt: number | null = null;
|
|
@@ -137,11 +175,15 @@ export const useSessionStart = (
|
|
|
137
175
|
if (state === "active") {
|
|
138
176
|
const since = backgroundedAt;
|
|
139
177
|
backgroundedAt = null;
|
|
140
|
-
|
|
178
|
+
// A new open re-runs the same vetting: the auto key must still be a durable one, and a
|
|
179
|
+
// degraded read that released its latches gets a fresh attempt rather than a cached verdict.
|
|
180
|
+
if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) openAutoDeviceKey(fire);
|
|
141
181
|
}
|
|
142
182
|
};
|
|
143
183
|
const sub = AppState.addEventListener("change", onChange);
|
|
144
184
|
return () => {
|
|
185
|
+
// A pending hydration must not fire an app-open for a mount that is already gone.
|
|
186
|
+
cancelled = true;
|
|
145
187
|
// RN >= 0.65 returns a subscription with remove(); guard for older shims.
|
|
146
188
|
if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
|
|
147
189
|
};
|
package/src/types.ts
CHANGED
|
@@ -54,10 +54,11 @@ export type OnboardingResult = {
|
|
|
54
54
|
/** The raw message thread, for custom downstream parsing. */
|
|
55
55
|
raw: Message[];
|
|
56
56
|
/**
|
|
57
|
-
* The backend's onboarding plan, when
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* byte-identically what it
|
|
57
|
+
* The backend's onboarding plan, present when the backend sent one — the kit never infers it
|
|
58
|
+
* from which flow ran. Whether a plan arrives is the backend's configuration, not the kit's. Any
|
|
59
|
+
* run the server finished without a plan leaves this `undefined` AND leaves the key off the
|
|
60
|
+
* result object entirely — so a host written before plans existed sees byte-identically what it
|
|
61
|
+
* always saw.
|
|
61
62
|
*
|
|
62
63
|
* ⚠️ The kit does NOT interpret this and does NOT validate it. It checks one structural fact (a
|
|
63
64
|
* plan is an object) and hands the payload straight through, unread, unlogged, and never attached
|
|
@@ -240,10 +241,19 @@ export type WireOnboardingProps = {
|
|
|
240
241
|
*
|
|
241
242
|
* ```tsx
|
|
242
243
|
* <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
|
|
243
|
-
* // no device id of your own?
|
|
244
|
-
*
|
|
244
|
+
* // no device id of your own? pass `storage` and leave this prop alone — the kit injects its
|
|
245
|
+
* // own key, and only after it has confirmed the key actually persists (see `autoJoinKey`).
|
|
246
|
+
* <WireOnboarding config={{ ...config, storage }} ... />
|
|
245
247
|
* ```
|
|
246
248
|
*
|
|
249
|
+
* ⛔ Do NOT hand-build the auto key with `activationJoinContext(resolveAutoDeviceKey({...}))`.
|
|
250
|
+
* `resolveAutoDeviceKey` is SYNCHRONOUS by contract: it hands back a freshly minted id and adopts
|
|
251
|
+
* the persisted one a storage read later, and it cannot tell you whether the id survives the
|
|
252
|
+
* launch at all. A key that differs on every launch corrupts `min_sessions` rather than merely
|
|
253
|
+
* leaving the join empty. The auto-join path does the awaited, durability-checked read for you;
|
|
254
|
+
* a host that genuinely wants the value in hand should await `hydrateDeviceIdentity` and refuse a
|
|
255
|
+
* `durable: false` record, which is exactly what the kit does internally.
|
|
256
|
+
*
|
|
247
257
|
* SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
|
|
248
258
|
* this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
|
|
249
259
|
* analytics surfaces mint and persist — so the default wiring joins. Anything you DO pass wins
|
package/src/utils/readPlan.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* readPlan — lift the backend's onboarding plan off the message thread.
|
|
3
3
|
*
|
|
4
|
-
* WHY IT EXISTS.
|
|
5
|
-
* on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the
|
|
6
|
-
* already consumes. The kit carries that payload out through `OnboardingResult.plan` and
|
|
7
|
-
* there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
|
|
4
|
+
* WHY IT EXISTS. When it sends a plan, the server appends a SECOND A2A DataPart to the turn it
|
|
5
|
+
* finishes on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the
|
|
6
|
+
* renderer already consumes. The kit carries that payload out through `OnboardingResult.plan` and
|
|
7
|
+
* stops there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
|
|
8
8
|
* attach it to any event — the plan is user-derived content, and deciding what it MEANS is the
|
|
9
9
|
* host's job (the kit/host boundary in `ai_rules/context_map.md`: the kit ends at the completion
|
|
10
10
|
* CTA).
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* so a throw here would cost the user the completion of an onboarding they already finished. Every
|
|
20
20
|
* step below is a runtime-guarded read.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
22
|
+
* WHICH RUNS CARRY A PLAN IS THE BACKEND'S CONFIGURATION, NOT THE KIT'S. This reader matches the
|
|
23
|
+
* `kind` MARKER below and never the flow that produced the turn, so it needs no knowledge of how
|
|
24
|
+
* the tenant is configured. A run that carries no plan yields `undefined`: unchanged, and fully
|
|
25
|
+
* supported.
|
|
23
26
|
*/
|
|
24
27
|
import type { Message } from "wireai-rn";
|
|
25
28
|
|