@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
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* error. A broken storage adapter must never gate or break onboarding.
|
|
16
16
|
*/
|
|
17
17
|
import {
|
|
18
|
+
READ_TIMED_OUT,
|
|
18
19
|
READ_TIMEOUT_MS,
|
|
19
20
|
withTimeout,
|
|
20
21
|
type WireOnboardingStorage,
|
|
@@ -47,19 +48,54 @@ export const readSettledPermissions = (
|
|
|
47
48
|
}
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Did the store ANSWER, or did it not? A read that timed out or threw is `unknown` — which is NOT
|
|
53
|
+
* the same fact as "this session has settled nothing", even though both used to arrive as `[]`.
|
|
54
|
+
*
|
|
55
|
+
* The distinction is load-bearing in two directions. A caller that treats `unknown` as empty
|
|
56
|
+
* (a) re-asks a permission the OS grants exactly once, and (b) persists a set it grew from that
|
|
57
|
+
* `[]`, SHRINKING the stored record to a single id. A successful read of a corrupt, absent, or
|
|
58
|
+
* other-session entry is a genuine `[]`: the store answered, and there is nothing here for us.
|
|
59
|
+
*/
|
|
60
|
+
export type SettledPermissionsOutcome =
|
|
61
|
+
| { status: "read"; ids: string[] }
|
|
62
|
+
| { status: "unknown" };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the settled ids for `sessionId`, reporting WHETHER the store answered. Never throws, never
|
|
66
|
+
* hangs past the shared read ceiling.
|
|
67
|
+
*/
|
|
68
|
+
export const loadSettledPermissionsOutcome = async (
|
|
52
69
|
storage: WireOnboardingStorage,
|
|
53
70
|
key: string,
|
|
54
71
|
sessionId: string,
|
|
55
|
-
): Promise<
|
|
72
|
+
): Promise<SettledPermissionsOutcome> => {
|
|
56
73
|
try {
|
|
57
|
-
|
|
74
|
+
const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
75
|
+
if (raw === READ_TIMED_OUT) return { status: "unknown" };
|
|
76
|
+
return { status: "read", ids: readSettledPermissions(raw, sessionId) };
|
|
58
77
|
} catch {
|
|
59
|
-
|
|
78
|
+
// The adapter threw or rejected: the store did not answer, so we know nothing either way.
|
|
79
|
+
return { status: "unknown" };
|
|
60
80
|
}
|
|
61
81
|
};
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Read the settled ids for `sessionId`, collapsing "the store did not answer" to `[]`.
|
|
85
|
+
*
|
|
86
|
+
* Kept as the module's simple reader for callers that have no way to act on the difference. The
|
|
87
|
+
* one caller that CAN — `WireOnboarding`, which would otherwise re-ask and then overwrite the
|
|
88
|
+
* stored record — uses {@link loadSettledPermissionsOutcome} instead.
|
|
89
|
+
*/
|
|
90
|
+
export const loadSettledPermissions = async (
|
|
91
|
+
storage: WireOnboardingStorage,
|
|
92
|
+
key: string,
|
|
93
|
+
sessionId: string,
|
|
94
|
+
): Promise<string[]> => {
|
|
95
|
+
const outcome = await loadSettledPermissionsOutcome(storage, key, sessionId);
|
|
96
|
+
return outcome.status === "read" ? outcome.ids : [];
|
|
97
|
+
};
|
|
98
|
+
|
|
63
99
|
/** Persist the settled ids for `sessionId` - fire-and-forget, all errors swallowed. */
|
|
64
100
|
export const saveSettledPermissions = (
|
|
65
101
|
storage: WireOnboardingStorage,
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -125,7 +125,7 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
125
125
|
// for the same reason `currentSession` does: tsup inlines this module into several bundles and a
|
|
126
126
|
// plain module-local `let` would give each bundle its own "process".
|
|
127
127
|
//
|
|
128
|
-
// ── WHY THE UNIT IS PINNED
|
|
128
|
+
// ── WHY THE UNIT IS PINNED, AND WHEN IT IS ALLOWED TO MOVE ───────────────────────────────────
|
|
129
129
|
// The first shape of this function read the two tiers LIVE on every call: the registered session id
|
|
130
130
|
// when there was one, else the process id. That let the UNIT change mid-launch, and React's own
|
|
131
131
|
// ordering guarantees it does. The gates call `bumpSessionCount` from a `useState` INITIALIZER,
|
|
@@ -137,39 +137,91 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
137
137
|
// remount: gate initializer → SESSION id ≠ the stored open → count = 2
|
|
138
138
|
//
|
|
139
139
|
// Two "sessions" inside one app open, which makes the fail-closed `minSessions: 2` default (added
|
|
140
|
-
// after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard.
|
|
141
|
-
// FIRST read pins whatever it resolved into the process slot and every later read returns that,
|
|
142
|
-
// regardless of what the session registry does afterwards. The client counter only has to be
|
|
143
|
-
// monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
|
|
144
|
-
// events, so nothing downstream needs the two ids to be identical.
|
|
140
|
+
// after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard.
|
|
145
141
|
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
142
|
+
// THE FIRST FIX PINNED THE WHOLE LAUNCH — one sample, never revisited. That closed the cold start
|
|
143
|
+
// and opened the opposite defect. `useLifecycleEvents` defines an app-open as a foreground after 30
|
|
144
|
+
// minutes
|
|
145
|
+
// in the background and fires a fresh `app.session_started` for it, but on iOS an app is SUSPENDED,
|
|
146
|
+
// not killed, so ONE JS process outlives many opens. A user who opens the app daily for a week
|
|
147
|
+
// produces seven server-side opens while `wire_review_<id>_sessions` stays at 1, and the local
|
|
148
|
+
// `minSessions: 2` rule is unsatisfiable on a phone that is never force-quit — which is most
|
|
149
|
+
// phones. It failed in the SAFE direction (a prompt that never shows, not one that shows too
|
|
150
|
+
// early), but the counter's own docstring promised "once per APP-OPEN" and did not deliver it.
|
|
151
|
+
//
|
|
152
|
+
// THE DISTINCTION THAT RESOLVES BOTH. The two ids in the cold-start trace name the SAME open; a
|
|
153
|
+
// LATER registration names a DIFFERENT one. Nothing else has to change: remember the registered id
|
|
154
|
+
// that was live when the pin was taken, and read it lazily.
|
|
155
|
+
//
|
|
156
|
+
// • pin unset → pin = the registered id ?? a freshly minted one, and remember which
|
|
157
|
+
// registered id that was (`undefined` when we had to mint).
|
|
158
|
+
// • remembered `undefined` → the FIRST registration merely NAMES the open the gate already
|
|
159
|
+
// counted under a minted id. Adopt it, leave the PIN alone. This is
|
|
160
|
+
// the cold-start case, and it stays shut.
|
|
161
|
+
// • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
|
|
162
|
+
//
|
|
163
|
+
// ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
|
|
164
|
+
// refused non-durable device key over a broken store) and a LATER foreground is the first
|
|
165
|
+
// registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
|
|
166
|
+
// open on an already storage-degraded device. Under-counting is the only direction a fail-closed
|
|
167
|
+
// gate may err in, so this is accepted rather than chased.
|
|
168
|
+
//
|
|
169
|
+
// The client counter only has to be monotone and to move once per real open; the server's own
|
|
170
|
+
// `min_sessions` still counts real `app.session_started` events, so nothing downstream needs the
|
|
171
|
+
// two ids to be identical.
|
|
172
|
+
//
|
|
173
|
+
// @globalSlot LIVE — it re-pins on a genuinely new app-open, so a reader that caches the returned
|
|
174
|
+
// string across opens re-freezes the counter at 1, and the local minSessions rule stops advancing
|
|
175
|
+
// forever on a suspended-not-killed app. The slot holds a PAIR rather than a bare id, because "is this a new
|
|
176
|
+
// open?" is only answerable against the registered id that was live when the pin was taken.
|
|
151
177
|
const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
|
|
152
178
|
|
|
153
|
-
|
|
179
|
+
/** The pinned open id plus the registered session id that was live at the moment it was pinned. */
|
|
180
|
+
type OpenPin = {
|
|
181
|
+
/** The id gate counting treats as THIS app-open. Never empty. */
|
|
182
|
+
openId: string;
|
|
183
|
+
/** The registered session id observed at pin time; `undefined` when `openId` was minted. */
|
|
184
|
+
observed: string | undefined;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
|
|
154
188
|
|
|
155
189
|
const openIdGlobal = globalThis as GlobalWithOpenId;
|
|
156
190
|
|
|
157
191
|
/**
|
|
158
|
-
* The id identifying THIS app-open for gate counting
|
|
159
|
-
*
|
|
160
|
-
*
|
|
192
|
+
* The id identifying THIS app-open for gate counting: the live per-open `session_id` once one has
|
|
193
|
+
* been registered, else a minted per-process id. Stable for the whole of an app-open and across
|
|
194
|
+
* every remount inside it, and it moves exactly once when a genuinely new open is registered.
|
|
195
|
+
* Never empty.
|
|
161
196
|
*/
|
|
162
197
|
export const currentOpenId = (): string => {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
198
|
+
const live = getCurrentSessionId();
|
|
199
|
+
const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
|
|
200
|
+
|
|
201
|
+
// First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
|
|
202
|
+
// rendered — that is the same unit the server counts — otherwise mint one and record that we did.
|
|
203
|
+
if (!pin) {
|
|
204
|
+
const openId = live ?? makeSessionId();
|
|
205
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
|
|
206
|
+
return openId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Nothing registered yet, or still the same registered open: the pin stands.
|
|
210
|
+
if (!live || live === pin.observed) return pin.openId;
|
|
211
|
+
|
|
212
|
+
// The first registration of the launch NAMES the open we already pinned under a minted id, so it
|
|
213
|
+
// is adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
|
|
214
|
+
if (pin.observed === undefined) {
|
|
215
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
|
|
216
|
+
return pin.openId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A second, DIFFERENT registration is a genuinely new app-open.
|
|
220
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
|
|
221
|
+
return live;
|
|
170
222
|
};
|
|
171
223
|
|
|
172
|
-
/** Test-only: forget the
|
|
224
|
+
/** Test-only: forget the pinned open so a unit test starts from a clean launch. */
|
|
173
225
|
export const resetProcessOpenId = (): void => {
|
|
174
226
|
openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
|
|
175
227
|
};
|
|
@@ -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
|