@wireai/activation 0.12.2 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -0
- package/README.md +5 -3
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +114 -35
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +114 -36
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BxEB37xt.d.ts → currentSession-D7zabMXK.d.ts} +161 -9
- package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-_GynvhzT.d.mts} +161 -9
- package/dist/index.d.mts +5 -15
- package/dist/index.d.ts +5 -15
- package/dist/index.js +232 -135
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +229 -134
- 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 +8 -6
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +8 -6
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +10 -3
- package/src/WireOnboarding.tsx +115 -32
- package/src/activation/wireActivation.ts +13 -7
- package/src/analytics/analyticsFacade.ts +11 -10
- package/src/analytics/currentSession.ts +6 -20
- package/src/analytics/eventQueue.ts +69 -1
- package/src/analytics/index.ts +1 -1
- package/src/analytics/reportClientEvent.ts +92 -29
- package/src/config/wireConfigFromEnv.ts +1 -10
- package/src/context/deviceId.ts +77 -16
- package/src/context/userContext.ts +4 -15
- package/src/identity/identityRecord.ts +123 -0
- package/src/identity/userIdentity.ts +45 -9
- package/src/index.ts +6 -4
- package/src/session-analytics/useLifecycleEvents.ts +10 -1
- package/src/types.ts +14 -0
- package/src/utils/deriveAnswers.ts +6 -2
- package/src/utils/readProgress.ts +4 -0
- package/src/utils/warnInDev.ts +33 -0
- package/src/components/DoneBlock.tsx +0 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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
|
@@ -356,8 +356,15 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
356
356
|
const wrappedActions = useCallback(
|
|
357
357
|
(messageId: string) => {
|
|
358
358
|
const base = makeActions(messageId);
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
// ⚠️ HOST VALIDATORS LIVE IN THE SAME KEY NAMESPACE `deriveAnswers` files answers under, so
|
|
360
|
+
// moving the answer key to `slot_id` without moving this lookup would silently UNBIND every
|
|
361
|
+
// host validator — no error, no warning, values just stop being checked. Try the slot first
|
|
362
|
+
// (matching the answer key), then fall back to the question key, so a host that has not yet
|
|
363
|
+
// renamed its validator map keeps working while a server rolls slots out.
|
|
364
|
+
const validators = validatorsRef.current;
|
|
365
|
+
const validator =
|
|
366
|
+
(progress.slot_id ? validators?.[progress.slot_id] : undefined) ??
|
|
367
|
+
(progress.key ? validators?.[progress.key] : undefined);
|
|
361
368
|
if (!validator) return base;
|
|
362
369
|
|
|
363
370
|
const out: Record<string, (...args: unknown[]) => void> = { ...base };
|
|
@@ -387,7 +394,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
387
394
|
}
|
|
388
395
|
return out;
|
|
389
396
|
},
|
|
390
|
-
[makeActions, progress.key],
|
|
397
|
+
[makeActions, progress.key, progress.slot_id],
|
|
391
398
|
);
|
|
392
399
|
|
|
393
400
|
// ── Render ────────────────────────────────────────────────────────────────
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -22,8 +22,9 @@ import { OnboardingFlow, DEFAULT_COPY } from "./OnboardingFlow";
|
|
|
22
22
|
import { onboardingComponents } from "./cards";
|
|
23
23
|
import { makeSessionId, reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
|
|
24
24
|
import { collectDeviceContext, type DeviceContext } from "./device/deviceContext";
|
|
25
|
-
import {
|
|
25
|
+
import { hydrateDeviceIdentity } from "./context/deviceId";
|
|
26
26
|
import { activationJoinContext } from "./context/userContext";
|
|
27
|
+
import { hostIdentity, resolveIdentity } from "./identity/identityRecord";
|
|
27
28
|
import { sanitizeUserId } from "./identity/userIdentity";
|
|
28
29
|
import {
|
|
29
30
|
clearPersistedSession,
|
|
@@ -34,22 +35,17 @@ import {
|
|
|
34
35
|
type LoadedSession,
|
|
35
36
|
} from "./session/persistedSession";
|
|
36
37
|
import type { OnboardingResult, WireOnboardingProps } from "./types";
|
|
37
|
-
|
|
38
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
39
|
-
declare const __DEV__: boolean | undefined;
|
|
40
|
-
|
|
41
|
-
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
|
|
42
|
-
* Same idiom as `analytics/analyticsFacade.warnInDev`. */
|
|
43
|
-
const warnInDev = (message: string): void => {
|
|
44
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
45
|
-
console.warn(message);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
38
|
+
import { warnInDev } from "./utils/warnInDev";
|
|
48
39
|
|
|
49
40
|
/** Ceiling on the auto-join-key storage read — a hung adapter degrades to no key, never a stuck
|
|
50
41
|
* loader. Matches the persisted-session read's own ceiling. */
|
|
51
42
|
const AUTO_JOIN_HYDRATION_TIMEOUT_MS = 1_500;
|
|
52
43
|
|
|
44
|
+
/** The settled result of the auto-join read: a key to inject, or `null` plus WHY the kit declined.
|
|
45
|
+
* `timeout` = the adapter never answered; `not-durable` = it answered but did not persist the id,
|
|
46
|
+
* so injecting it would mean a different key on every launch. */
|
|
47
|
+
type AutoJoinOutcome = { key: string } | { key: null; reason: "timeout" | "not-durable" };
|
|
48
|
+
|
|
53
49
|
export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
54
50
|
config,
|
|
55
51
|
theme,
|
|
@@ -109,8 +105,23 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
109
105
|
// onboarding session to everything the app reports later, and the prop that carries it is optional
|
|
110
106
|
// and named `userContext` — so forgetting it is the default, and the failure is a silent zero in the
|
|
111
107
|
// `activated` funnel rather than an error. Two real consumers shipped without it.
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
//
|
|
109
|
+
// A host-supplied key is also RECORDED on the process-wide provenance registry, so a sibling
|
|
110
|
+
// surface can later ask "does this app own a device id?" — see the K9 warning below. The registry
|
|
111
|
+
// write is idempotent (same key, same value), so running it from a memo is safe under StrictMode's
|
|
112
|
+
// double-invoke; it lives here rather than in an effect so the answer is already true for any
|
|
113
|
+
// surface constructed later in the same tick.
|
|
114
|
+
const hostJoinIdentity = useMemo(
|
|
115
|
+
() =>
|
|
116
|
+
resolveIdentity({
|
|
117
|
+
value: userContextStable?.device_key,
|
|
118
|
+
space: "device",
|
|
119
|
+
source: "host",
|
|
120
|
+
scope: config.appId,
|
|
121
|
+
}),
|
|
122
|
+
[userContextStable, config.appId],
|
|
123
|
+
);
|
|
124
|
+
const missingJoinKey = !hostJoinIdentity;
|
|
114
125
|
|
|
115
126
|
// AUTO-JOIN (0.12.2). When the host supplied no join key, the kit supplies its OWN — the same
|
|
116
127
|
// per-install `device_key` the analytics surfaces auto-mint and persist — so the default wiring
|
|
@@ -119,12 +130,21 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
119
130
|
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched.
|
|
120
131
|
// • `autoJoinKey !== false` — the documented opt-out for a host that genuinely wants an
|
|
121
132
|
// unlinked onboarding session; opting out restores the pre-0.12.2 behavior exactly.
|
|
122
|
-
// • `storage` —
|
|
123
|
-
// would carry a DIFFERENT key. That is worse than no key: the server counts
|
|
124
|
-
// by distinct opens grouped on `device_key`, so a per-launch key corrupts the
|
|
125
|
-
// than leaving it empty
|
|
133
|
+
// • `storage` that actually WORKS — without persistence the auto id is process-scoped, so every
|
|
134
|
+
// launch would carry a DIFFERENT key. That is worse than no key: the server counts
|
|
135
|
+
// `min_sessions` by distinct opens grouped on `device_key`, so a per-launch key corrupts the
|
|
136
|
+
// counter rather than leaving it empty, and it inflates distinct-device counts on top. Same
|
|
137
|
+
// reason `useLifecycleEvents` gates its own fallback on storage.
|
|
126
138
|
//
|
|
127
|
-
//
|
|
139
|
+
// ⚠️ 0.13.0 (K1): the third condition used to be `Boolean(storage)` — the PRESENCE of the prop,
|
|
140
|
+
// never the SUCCESS of the write. A REJECTING adapter (a locked / full / permission-denied
|
|
141
|
+
// AsyncStorage, the most common real breakage) therefore walked the per-launch key in through the
|
|
142
|
+
// front door: `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, and a
|
|
143
|
+
// string is a string. The gate now reads `IdentityRecord.durable`, which is `true` ONLY when the id
|
|
144
|
+
// was adopted from storage or written to it successfully — so a broken adapter lands on exactly the
|
|
145
|
+
// same "declined, and here is why" path as no adapter at all.
|
|
146
|
+
//
|
|
147
|
+
// It resolves through `hydrateDeviceIdentity`, never the sync `resolveAutoDeviceKey`: the sync
|
|
128
148
|
// contract returns the freshly minted id and adopts the persisted one milliseconds later, which
|
|
129
149
|
// is exactly the 0.12.1 H1 defect — the key stamped here must be the one the analytics side
|
|
130
150
|
// stamps, not a fresh mint per launch. The value is therefore awaited BEHIND THE LOADER GATE
|
|
@@ -133,33 +153,35 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
133
153
|
// provider mounted would recreate the A2A adapter and drop the server-learned `contextId`.
|
|
134
154
|
const autoJoinPossible = Boolean(storage) && autoJoinKey !== false;
|
|
135
155
|
const wantsAutoJoin = missingJoinKey && autoJoinPossible;
|
|
136
|
-
// `undefined` = the hydration is still in flight (the gate below holds)
|
|
137
|
-
//
|
|
138
|
-
const [
|
|
156
|
+
// `undefined` = the hydration is still in flight (the gate below holds). Otherwise a settled
|
|
157
|
+
// outcome: a `key` to inject, or `null` plus the reason the kit declined, which the warning names.
|
|
158
|
+
const [autoJoinOutcome, setAutoJoinOutcome] = useState<AutoJoinOutcome | undefined>(undefined);
|
|
139
159
|
|
|
140
160
|
useEffect(() => {
|
|
141
|
-
if (!wantsAutoJoin ||
|
|
161
|
+
if (!wantsAutoJoin || autoJoinOutcome !== undefined || !storage) return;
|
|
142
162
|
let cancelled = false;
|
|
143
163
|
// Ceiling on the read, the same discipline as `loadPersistedSession`: a hung storage adapter
|
|
144
164
|
// must degrade to "no auto key", never to a stuck loader over the host's onboarding. Giving up
|
|
145
165
|
// is deliberately NOT "fall back to the synchronous mint" — that key is per-launch, which is
|
|
146
166
|
// the corruption this feature is gated on `storage` to avoid.
|
|
147
167
|
const timer = setTimeout(() => {
|
|
148
|
-
if (!cancelled)
|
|
168
|
+
if (!cancelled) setAutoJoinOutcome({ key: null, reason: "timeout" });
|
|
149
169
|
}, AUTO_JOIN_HYDRATION_TIMEOUT_MS);
|
|
150
|
-
void
|
|
170
|
+
void hydrateDeviceIdentity({ appId: config.appId, storage }).then((identity) => {
|
|
151
171
|
if (cancelled) return;
|
|
152
172
|
clearTimeout(timer);
|
|
153
|
-
|
|
173
|
+
setAutoJoinOutcome(
|
|
174
|
+
identity?.durable ? { key: identity.value } : { key: null, reason: "not-durable" },
|
|
175
|
+
);
|
|
154
176
|
});
|
|
155
177
|
return () => {
|
|
156
178
|
cancelled = true;
|
|
157
179
|
clearTimeout(timer);
|
|
158
180
|
};
|
|
159
|
-
}, [wantsAutoJoin,
|
|
181
|
+
}, [wantsAutoJoin, autoJoinOutcome, config.appId, storage]);
|
|
160
182
|
|
|
161
|
-
const injectedJoinKey = wantsAutoJoin
|
|
162
|
-
const autoJoinPending = wantsAutoJoin &&
|
|
183
|
+
const injectedJoinKey = wantsAutoJoin ? (autoJoinOutcome?.key ?? undefined) : undefined;
|
|
184
|
+
const autoJoinPending = wantsAutoJoin && autoJoinOutcome === undefined;
|
|
163
185
|
|
|
164
186
|
// What actually goes on the wire: the host's context, with the auto join key merged in ONLY when
|
|
165
187
|
// the host left the slot empty. Every other key the host passed survives untouched, and a host
|
|
@@ -172,6 +194,36 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
172
194
|
[userContextStable, injectedJoinKey],
|
|
173
195
|
);
|
|
174
196
|
|
|
197
|
+
// THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS (K5), exactly as `startupUserIdRef`
|
|
198
|
+
// below freezes the user id, and for the identical reason: `userContext` feeds the `llm` memo, and
|
|
199
|
+
// wireai-rn recreates its A2A adapter whenever the `llm` identity changes — the adapter ctor resets
|
|
200
|
+
// `contextId` (`this.contextId = void 0`), so the server-learned session id is dropped.
|
|
201
|
+
//
|
|
202
|
+
// THE TEARDOWN THIS PREVENTS: a host that resolves its device key ASYNCHRONOUSLY renders once
|
|
203
|
+
// without it → auto-join injects `wdev_*` → the host key lands a tick later → `effectiveUserContext`
|
|
204
|
+
// flips → the memo rebuilds → the adapter is recreated → the user restarts onboarding mid-flow and
|
|
205
|
+
// the server is left holding an orphaned session. It is not a hypothetical shape: it is the default
|
|
206
|
+
// shape of any host that reads its device id out of async storage.
|
|
207
|
+
//
|
|
208
|
+
// Frozen at gate-open rather than at first render because the auto-join key itself arrives
|
|
209
|
+
// asynchronously — freezing earlier would freeze `undefined`. The value is carried as a JSON STRING
|
|
210
|
+
// so it can sit in a dependency array by value; a ref alone would not re-run the memo when it fills.
|
|
211
|
+
// Client events deliberately keep reading the LIVE `effectiveUserContext`: they are posted one at a
|
|
212
|
+
// time and carry no adapter, so late host context enriches them at no risk — the same split the
|
|
213
|
+
// user id already uses (frozen in session-start metadata, live via the `identify` event).
|
|
214
|
+
const frozenWireContextRef = useRef<string | null>(null);
|
|
215
|
+
if (!autoJoinPending && frozenWireContextRef.current === null) {
|
|
216
|
+
frozenWireContextRef.current = effectiveUserContext ? JSON.stringify(effectiveUserContext) : "";
|
|
217
|
+
}
|
|
218
|
+
const startupUserContextKey = frozenWireContextRef.current ?? "";
|
|
219
|
+
const startupUserContext = useMemo<Record<string, string | number | boolean> | undefined>(
|
|
220
|
+
() =>
|
|
221
|
+
startupUserContextKey
|
|
222
|
+
? (JSON.parse(startupUserContextKey) as Record<string, string | number | boolean>)
|
|
223
|
+
: undefined,
|
|
224
|
+
[startupUserContextKey],
|
|
225
|
+
);
|
|
226
|
+
|
|
175
227
|
// Dev-only warning (never a throw, never a wire change), once per mount, naming the exact fix.
|
|
176
228
|
// Reconciled with auto-join: it fires only when the gap is still OPEN — injection was impossible
|
|
177
229
|
// (no `storage`), the host opted out, or the read gave up. When auto-join covered the gap the
|
|
@@ -184,7 +236,11 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
184
236
|
: !storage
|
|
185
237
|
? "it got no `storage` prop, and without persistence its key would be different on every " +
|
|
186
238
|
"launch, which corrupts min_sessions instead of merely leaving the join empty."
|
|
187
|
-
:
|
|
239
|
+
: autoJoinOutcome?.key === null && autoJoinOutcome.reason === "not-durable"
|
|
240
|
+
? "your `storage` adapter answered but could not persist the key (the read or the write " +
|
|
241
|
+
"failed), so the key would be different on every launch, which corrupts min_sessions " +
|
|
242
|
+
"instead of merely leaving the join empty. Check the adapter you passed as `storage`."
|
|
243
|
+
: "your `storage` adapter did not answer in time.";
|
|
188
244
|
useEffect(() => {
|
|
189
245
|
if (!warnMissingJoinKey) return;
|
|
190
246
|
warnInDev(
|
|
@@ -198,6 +254,31 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
198
254
|
);
|
|
199
255
|
}, [warnMissingJoinKey, autoJoinReason]);
|
|
200
256
|
|
|
257
|
+
// THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE (K9). 0.12.2 suppressed the missing-join-key
|
|
258
|
+
// warning whenever injection succeeded, which is right when the host genuinely owns no device id —
|
|
259
|
+
// and wrong when it owns one and simply forgot it HERE. That host used to get a loud warning and
|
|
260
|
+
// now gets a silent third id space: its app events under its own key, this onboarding session under
|
|
261
|
+
// `wdev_*`, and `activated` still zero. Nothing a single mount can see distinguishes the two cases,
|
|
262
|
+
// which is why the provenance registry exists: any surface constructed with a HOST-supplied device
|
|
263
|
+
// key records it, so this mount can ask whether the process demonstrably owns one.
|
|
264
|
+
//
|
|
265
|
+
// It WARNS rather than declining to inject: declining would leave the onboarding side with no key
|
|
266
|
+
// at all, which is strictly worse than a self-consistent one. It also does not silently ADOPT the
|
|
267
|
+
// other surface's key — that would be the kit guessing which of two ids a host meant, on the
|
|
268
|
+
// strength of construction order, and quietly changing what lands on the wire.
|
|
269
|
+
const hostKeyElsewhere = missingJoinKey ? hostIdentity("device", config.appId) : undefined;
|
|
270
|
+
const warnHostKeyElsewhere = Boolean(hostKeyElsewhere) && Boolean(injectedJoinKey);
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!warnHostKeyElsewhere) return;
|
|
273
|
+
warnInDev(
|
|
274
|
+
"[wireai] <WireOnboarding> got no user_context.device_key, so the kit injected its own — but " +
|
|
275
|
+
"another surface in this app was constructed WITH a host-supplied device key " +
|
|
276
|
+
`("${hostKeyElsewhere}"). Your app events carry that id and this onboarding session carries ` +
|
|
277
|
+
"the kit's, so the two land in disjoint id spaces and the `activated` funnel still reads " +
|
|
278
|
+
"zero. Pass the SAME id here: userContext={activationJoinContext(deviceKey)}.",
|
|
279
|
+
);
|
|
280
|
+
}, [warnHostKeyElsewhere, hostKeyElsewhere]);
|
|
281
|
+
|
|
201
282
|
// The one case auto-join would otherwise SILENCE and must not: the host hand-wrote
|
|
202
283
|
// `userContext={{ deviceKey }}`. That bucket is not the wire key, so the kit injects its own —
|
|
203
284
|
// but the host demonstrably owns an id, and their app-side events carry THAT one, so the two
|
|
@@ -311,7 +392,9 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
311
392
|
// Device snapshot + host-injected user context ride the session-start metadata so the
|
|
312
393
|
// backend can segment the funnel. Old servers ignore these unknown keys (backward compat).
|
|
313
394
|
device,
|
|
314
|
-
|
|
395
|
+
// The FROZEN wire context, never the live one — see the freeze note above. A change here
|
|
396
|
+
// recreates the A2A adapter and drops `contextId`.
|
|
397
|
+
...(startupUserContext ? { userContext: startupUserContext } : {}),
|
|
315
398
|
// The session-start user id (from the ref) rides the session-start metadata so the
|
|
316
399
|
// server binds the session to a real user at creation. Reading the ref — not
|
|
317
400
|
// `boundUserId` — keeps this memo off the userId dependency, so a mid-session change
|
|
@@ -320,7 +403,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
320
403
|
},
|
|
321
404
|
timeoutMs: 60_000,
|
|
322
405
|
};
|
|
323
|
-
}, [config.serverUrl, config.appId, config.apiKey, metadataStable, sessionId, componentsStable, device,
|
|
406
|
+
}, [config.serverUrl, config.appId, config.apiKey, metadataStable, sessionId, componentsStable, device, startupUserContext]);
|
|
324
407
|
|
|
325
408
|
// Where client-reported events are POSTed (`/v1/events`). Same tenant creds as the flow.
|
|
326
409
|
const reportTarget = useMemo<ClientEventTarget>(
|
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
type ClientEventTarget,
|
|
30
30
|
} from "../analytics/reportClientEvent";
|
|
31
31
|
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
32
|
-
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
32
|
+
import { cleanString, resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
33
|
+
import { resolveIdentity } from "../identity/identityRecord";
|
|
33
34
|
import { detectAppVersion } from "../device/appVersion";
|
|
34
35
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
35
36
|
import {
|
|
@@ -38,12 +39,8 @@ import {
|
|
|
38
39
|
subscribeActivationRevalidation,
|
|
39
40
|
} from "./revalidation";
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
const clean =
|
|
43
|
-
if (typeof value !== "string") return undefined;
|
|
44
|
-
const trimmed = value.trim();
|
|
45
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
46
|
-
};
|
|
42
|
+
/** The shared trim helper (was a byte-identical private copy named `clean` in this file). */
|
|
43
|
+
const clean = cleanString;
|
|
47
44
|
|
|
48
45
|
/**
|
|
49
46
|
* Tenant transport + context inputs for {@link createWireActivation}. `serverUrl`/`apiKey` are the
|
|
@@ -109,6 +106,15 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
109
106
|
// `createAnalytics` instance carry the SAME `device_key` for the same install. Minting locally here
|
|
110
107
|
// gave one install two auto ids — see the registry note in context/deviceId.ts.
|
|
111
108
|
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
109
|
+
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
110
|
+
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
111
|
+
// and forgot it here" (the silent third id space — K9). Recording only; nothing reads it on this path.
|
|
112
|
+
resolveIdentity({
|
|
113
|
+
value: explicitDeviceKey,
|
|
114
|
+
space: "device",
|
|
115
|
+
source: "host",
|
|
116
|
+
scope: config.appId,
|
|
117
|
+
});
|
|
112
118
|
const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
|
|
113
119
|
appId: config.appId,
|
|
114
120
|
// An explicit key opts out of minting AND persisting (unchanged contract).
|
|
@@ -38,17 +38,9 @@ import {
|
|
|
38
38
|
} from "../context/userContext";
|
|
39
39
|
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
40
40
|
import { detectAppVersion } from "../device/appVersion";
|
|
41
|
+
import { resolveIdentity } from "../identity/identityRecord";
|
|
41
42
|
import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
|
|
42
|
-
|
|
43
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
44
|
-
declare const __DEV__: boolean | undefined;
|
|
45
|
-
|
|
46
|
-
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
|
|
47
|
-
const warnInDev = (message: string): void => {
|
|
48
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
49
|
-
console.warn(message);
|
|
50
|
-
}
|
|
51
|
-
};
|
|
43
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
52
44
|
|
|
53
45
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
54
46
|
export type AnalyticsProps = Record<string, unknown>;
|
|
@@ -204,6 +196,15 @@ export const createAnalytics = (
|
|
|
204
196
|
typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
|
|
205
197
|
? config.userContext.deviceKey.trim()
|
|
206
198
|
: undefined;
|
|
199
|
+
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
200
|
+
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
201
|
+
// and forgot it here" (the silent third id space — K9). Recording only; nothing reads it here.
|
|
202
|
+
resolveIdentity({
|
|
203
|
+
value: hostDeviceKeyAtInit,
|
|
204
|
+
space: "device",
|
|
205
|
+
source: "host",
|
|
206
|
+
scope: config.appId,
|
|
207
|
+
});
|
|
207
208
|
// The auto id comes from the ONE process-wide registry (`resolveAutoDeviceKey`), NOT a mint local to
|
|
208
209
|
// this instance. A host that also builds a `createWireActivation` instance used to get a SECOND,
|
|
209
210
|
// different auto id for the same install, splitting `device_key` across two id spaces — see the
|
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
* app-open: it warns once in dev, naming the fix.
|
|
49
49
|
*/
|
|
50
50
|
import { makeSessionId } from "./reportClientEvent";
|
|
51
|
+
// The shared primitive returns whether it ACTUALLY warned, which the once-flag below depends on:
|
|
52
|
+
// marking "already warned" after a prod no-op would burn the single warning and leave the one dev
|
|
53
|
+
// build that needed it silent. (The old local copy justified itself as keeping this module
|
|
54
|
+
// import-free for the tree-shaken analytics bundle; that had already lapsed — it imports
|
|
55
|
+
// `makeSessionId` right here — and `utils/warnInDev` has no imports of its own.)
|
|
56
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
@@ -83,26 +89,6 @@ export const resetCurrentSessionId = (): void => {
|
|
|
83
89
|
globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
|
|
84
90
|
};
|
|
85
91
|
|
|
86
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
87
|
-
declare const __DEV__: boolean | undefined;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
|
|
91
|
-
* Same idiom as `analyticsFacade.warnInDev` — deliberately duplicated rather than imported, so this
|
|
92
|
-
* module keeps its zero-import-weight for the tree-shaken analytics bundle.
|
|
93
|
-
*
|
|
94
|
-
* Returns whether it ACTUALLY warned, so the caller's once-flag is spent on a warning a developer
|
|
95
|
-
* saw. Marking "already warned" after a no-op would burn the single warning in prod, and the one
|
|
96
|
-
* dev build that needed it would then run silent.
|
|
97
|
-
*/
|
|
98
|
-
const warnInDev = (message: string): boolean => {
|
|
99
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
100
|
-
console.warn(message);
|
|
101
|
-
return true;
|
|
102
|
-
}
|
|
103
|
-
return false;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
92
|
/** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
|
|
107
93
|
const MINT_WARNING =
|
|
108
94
|
"[wireai] No app-open session was registered, so a session id was minted for this event " +
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type ClientEventTarget,
|
|
32
32
|
} from "./reportClientEvent";
|
|
33
33
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
34
35
|
|
|
35
36
|
/** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
|
|
36
37
|
export type EnvelopeSource = ContextEnvelope | (() => ContextEnvelope | undefined);
|
|
@@ -85,6 +86,66 @@ const DEFAULTS = {
|
|
|
85
86
|
/** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
|
|
86
87
|
const READ_TIMEOUT_MS = 1500;
|
|
87
88
|
|
|
89
|
+
// ── One storage slot per QUEUE, not per appId (K4) ───────────────────────────────────────────────
|
|
90
|
+
//
|
|
91
|
+
// The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
|
|
92
|
+
// the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
|
|
93
|
+
// persisted backlog while keeping SEPARATE in-memory buffers. Every symptom of that is silent:
|
|
94
|
+
// each `persist()` overwrites the other's blob with its own view of "pending"; a queue that drains to
|
|
95
|
+
// empty calls `removeItem` and DELETES a sibling's still-pending events; and on relaunch whatever
|
|
96
|
+
// survived is loaded by both instances and sent twice. `useLifecycleEvents` already avoided all of it
|
|
97
|
+
// by handing its queue a dedicated explicit key — this generalizes that.
|
|
98
|
+
//
|
|
99
|
+
// A `Symbol.for` slot for the same reason as every other registry here: tsup inlines a copy of this
|
|
100
|
+
// module into each bundle, and a plain module `let` would let the `.` and `./analytics` copies each
|
|
101
|
+
// think they were the first claimant of the same key.
|
|
102
|
+
const QUEUE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:eventQueueKeys");
|
|
103
|
+
|
|
104
|
+
type GlobalWithQueueKeys = typeof globalThis & { [QUEUE_KEY_SLOT]?: Set<string> };
|
|
105
|
+
|
|
106
|
+
const queueKeyGlobal = globalThis as GlobalWithQueueKeys;
|
|
107
|
+
|
|
108
|
+
const claimedQueueKeys = (): Set<string> => {
|
|
109
|
+
const existing = queueKeyGlobal[QUEUE_KEY_SLOT];
|
|
110
|
+
if (existing) return existing;
|
|
111
|
+
const created = new Set<string>();
|
|
112
|
+
queueKeyGlobal[QUEUE_KEY_SLOT] = created;
|
|
113
|
+
return created;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Claim `preferred` for this queue, or the next free `preferred#N` when a live queue already holds it.
|
|
118
|
+
*
|
|
119
|
+
* ONLY the appId-derived DEFAULT is ever rotated. An EXPLICIT `storageKey` is the host (or
|
|
120
|
+
* `useLifecycleEvents`) declaring which slot a queue owns, and that hook re-creates its queue on every
|
|
121
|
+
* remount — rotating there would silently walk it off its own backlog once per remount, which is a
|
|
122
|
+
* worse bug than the one being fixed.
|
|
123
|
+
*/
|
|
124
|
+
const claimQueueKey = (preferred: string, explicit: boolean): string => {
|
|
125
|
+
const claimed = claimedQueueKeys();
|
|
126
|
+
if (explicit || !claimed.has(preferred)) {
|
|
127
|
+
claimed.add(preferred);
|
|
128
|
+
return preferred;
|
|
129
|
+
}
|
|
130
|
+
let ordinal = 2;
|
|
131
|
+
while (claimed.has(`${preferred}#${ordinal}`)) ordinal++;
|
|
132
|
+
const key = `${preferred}#${ordinal}`;
|
|
133
|
+
claimed.add(key);
|
|
134
|
+
warnInDev(
|
|
135
|
+
`[wireai] a second event queue was created for the same appId, and "${preferred}" is already ` +
|
|
136
|
+
"claimed by a live one. Two queues sharing one storage slot overwrite each other's backlog, " +
|
|
137
|
+
"delete each other's pending events when one drains to empty, and double-send on relaunch, so " +
|
|
138
|
+
`this queue was given "${key}" instead. Prefer ONE analytics instance per app; if you really ` +
|
|
139
|
+
"need two, pass an explicit `storageKey` to each so the slots are yours to reason about.",
|
|
140
|
+
);
|
|
141
|
+
return key;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Test-only: forget every claimed queue key. A real RELAUNCH is a new process, so a test that
|
|
145
|
+
* simulates one in-process must call this or its second queue reads as a concurrent sibling.
|
|
146
|
+
* Exported from `@wireai/activation/analytics`, matching `resetAutoDeviceKeys` / `resetCurrentSessionId`. */
|
|
147
|
+
export const resetEventQueueKeys = (): void => claimedQueueKeys().clear();
|
|
148
|
+
|
|
88
149
|
/** Internal buffered item. `id` is a local monotonic handle for deterministic dequeue-after-ack;
|
|
89
150
|
* it is NEVER sent to the server. `sig` is the de-dup signature (serialized stamped event). */
|
|
90
151
|
type QueuedItem = { id: number; event: ClientEvent; sig: string };
|
|
@@ -137,7 +198,14 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
|
|
|
137
198
|
export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
138
199
|
const target = options.target;
|
|
139
200
|
const storage = options.storage;
|
|
140
|
-
|
|
201
|
+
// One slot per QUEUE (K4): an explicit key is taken verbatim; the appId-derived default is rotated
|
|
202
|
+
// to `…#2` when a live queue already holds it, so two instances can never share one backlog.
|
|
203
|
+
//
|
|
204
|
+
// Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
|
|
205
|
+
// storage-less queue (the documented degraded in-memory mode) never reads or writes the key — so
|
|
206
|
+
// claiming there would warn about a collision that cannot happen.
|
|
207
|
+
const defaultKey = options.storageKey ?? `wireai:evtq:${options.appId ?? "default"}`;
|
|
208
|
+
const key = storage ? claimQueueKey(defaultKey, options.storageKey !== undefined) : defaultKey;
|
|
141
209
|
const maxSize = options.maxSize ?? DEFAULTS.maxSize;
|
|
142
210
|
const batchSize = options.batchSize ?? DEFAULTS.batchSize;
|
|
143
211
|
const baseBackoffMs = options.baseBackoffMs ?? DEFAULTS.baseBackoffMs;
|
package/src/analytics/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ export { buildContextEnvelope } from "./contextEnvelope";
|
|
|
49
49
|
export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
|
|
50
50
|
|
|
51
51
|
// ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
|
|
52
|
-
export { createEventQueue } from "./eventQueue";
|
|
52
|
+
export { createEventQueue, resetEventQueueKeys } from "./eventQueue";
|
|
53
53
|
export type { EventQueue, EventQueueOptions, EnvelopeSource } from "./eventQueue";
|
|
54
54
|
|
|
55
55
|
// ─── Developer-facing analytics façade (track / screen / identify) over the queue ─
|