@wireai/activation 0.11.0 → 0.12.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 +62 -8
- package/CHANGELOG.md +188 -3
- package/INTEGRATION_PROMPT.md +25 -2
- package/README.md +112 -1
- package/dist/analytics/index.d.mts +18 -6
- package/dist/analytics/index.d.ts +18 -6
- package/dist/analytics/index.js +151 -41
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +147 -42
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +16 -0
- package/dist/coachmarks/index.d.ts +16 -0
- package/dist/coachmarks/index.js +19 -13
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +19 -13
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-C0_odnIW.d.mts → currentSession-BlCeDP0f.d.mts} +145 -33
- package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-BxEB37xt.d.ts} +145 -33
- package/dist/index.d.mts +243 -53
- package/dist/index.d.ts +243 -53
- package/dist/index.js +531 -156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +517 -157
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +1 -1
- package/dist/questionnaire/index.d.ts +1 -1
- package/dist/questionnaire/index.js +79 -14
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +79 -14
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +2 -2
- package/dist/reviews/index.d.ts +2 -2
- package/dist/reviews/index.js +103 -16
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +103 -16
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +15 -6
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +15 -6
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
- package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
- package/llms.txt +3 -0
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +140 -5
- package/src/activation/useWireActivation.ts +12 -1
- package/src/activation/wireActivation.ts +44 -25
- package/src/analytics/analyticsFacade.ts +54 -29
- package/src/analytics/currentSession.ts +83 -0
- package/src/analytics/eventQueue.ts +9 -1
- package/src/analytics/index.ts +20 -1
- package/src/analytics/reportClientEvent.ts +42 -0
- package/src/analytics/screenTracking.ts +6 -1
- package/src/analytics/useAnalytics.ts +22 -1
- package/src/coachmarks/runtime.ts +53 -17
- package/src/config/wireConfigFromEnv.ts +46 -2
- package/src/context/deviceId.ts +173 -0
- package/src/context/userContext.ts +18 -0
- package/src/index.ts +54 -2
- package/src/questionnaire/runtime.ts +13 -2
- package/src/questionnaire/useQuestionnaireGate.ts +11 -4
- package/src/revenuecat/index.ts +55 -0
- package/src/revenuecat/purchaseEvents.ts +167 -0
- package/src/revenuecat/revenueCatBridge.ts +221 -0
- package/src/revenuecat/types.ts +95 -0
- package/src/reviews/runtime.ts +153 -1
- package/src/reviews/transport.ts +21 -2
- package/src/reviews/useReviewGate.ts +14 -4
- package/src/session-analytics/lifecycle.ts +9 -2
- package/src/session-analytics/reportSessionStart.ts +15 -4
- package/src/session-analytics/useLifecycleEvents.ts +83 -27
- package/src/session-analytics/useSessionStart.ts +37 -1
- package/src/types.ts +41 -4
package/src/WireOnboarding.tsx
CHANGED
|
@@ -22,6 +22,8 @@ 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 { hydrateAutoDeviceKey } from "./context/deviceId";
|
|
26
|
+
import { activationJoinContext } from "./context/userContext";
|
|
25
27
|
import { sanitizeUserId } from "./identity/userIdentity";
|
|
26
28
|
import {
|
|
27
29
|
clearPersistedSession,
|
|
@@ -33,6 +35,21 @@ import {
|
|
|
33
35
|
} from "./session/persistedSession";
|
|
34
36
|
import type { OnboardingResult, WireOnboardingProps } from "./types";
|
|
35
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
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Ceiling on the auto-join-key storage read — a hung adapter degrades to no key, never a stuck
|
|
50
|
+
* loader. Matches the persisted-session read's own ceiling. */
|
|
51
|
+
const AUTO_JOIN_HYDRATION_TIMEOUT_MS = 1_500;
|
|
52
|
+
|
|
36
53
|
export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
37
54
|
config,
|
|
38
55
|
theme,
|
|
@@ -56,6 +73,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
56
73
|
retainSessionOnComplete,
|
|
57
74
|
userContext,
|
|
58
75
|
userId,
|
|
76
|
+
autoJoinKey = true,
|
|
59
77
|
}) => {
|
|
60
78
|
// The host's own opaque user id (trimmed + capped, NO PII) so onboarding sessions can be
|
|
61
79
|
// reconciled to real users later. `sanitizeUserId` is a pure string transform → the memoized
|
|
@@ -87,12 +105,125 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
87
105
|
[userContextKey],
|
|
88
106
|
);
|
|
89
107
|
|
|
108
|
+
// THE JOIN KEY, or the absence of it. `user_context.device_key` is the ONLY thing that stitches an
|
|
109
|
+
// onboarding session to everything the app reports later, and the prop that carries it is optional
|
|
110
|
+
// and named `userContext` — so forgetting it is the default, and the failure is a silent zero in the
|
|
111
|
+
// `activated` funnel rather than an error. Two real consumers shipped without it.
|
|
112
|
+
const missingJoinKey =
|
|
113
|
+
typeof userContextStable?.device_key !== "string" || !userContextStable.device_key.trim();
|
|
114
|
+
|
|
115
|
+
// AUTO-JOIN (0.12.2). When the host supplied no join key, the kit supplies its OWN — the same
|
|
116
|
+
// per-install `device_key` the analytics surfaces auto-mint and persist — so the default wiring
|
|
117
|
+
// produces a joined funnel instead of a silent zero. Three conditions, all necessary:
|
|
118
|
+
//
|
|
119
|
+
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched.
|
|
120
|
+
// • `autoJoinKey !== false` — the documented opt-out for a host that genuinely wants an
|
|
121
|
+
// unlinked onboarding session; opting out restores the pre-0.12.2 behavior exactly.
|
|
122
|
+
// • `storage` — WITHOUT persistence `resolveAutoDeviceKey` is process-scoped, so every launch
|
|
123
|
+
// would carry a DIFFERENT key. That is worse than no key: the server counts `min_sessions`
|
|
124
|
+
// by distinct opens grouped on `device_key`, so a per-launch key corrupts the counter rather
|
|
125
|
+
// than leaving it empty. Same reason `useLifecycleEvents` gates its own fallback on storage.
|
|
126
|
+
//
|
|
127
|
+
// It resolves through `hydrateAutoDeviceKey`, never the sync `resolveAutoDeviceKey`: the sync
|
|
128
|
+
// contract returns the freshly minted id and adopts the persisted one milliseconds later, which
|
|
129
|
+
// is exactly the 0.12.1 H1 defect — the key stamped here must be the one the analytics side
|
|
130
|
+
// stamps, not a fresh mint per launch. The value is therefore awaited BEHIND THE LOADER GATE
|
|
131
|
+
// below (the same gate the persisted-session read already holds, and it is only ever open when
|
|
132
|
+
// `storage` is present anyway), because `userContext` feeds the `llm` memo: swapping it after the
|
|
133
|
+
// provider mounted would recreate the A2A adapter and drop the server-learned `contextId`.
|
|
134
|
+
const autoJoinPossible = Boolean(storage) && autoJoinKey !== false;
|
|
135
|
+
const wantsAutoJoin = missingJoinKey && autoJoinPossible;
|
|
136
|
+
// `undefined` = the hydration is still in flight (the gate below holds); a string = the key to
|
|
137
|
+
// inject; `null` = gave up (see the timeout note in the effect) so the gate opens with no key.
|
|
138
|
+
const [autoJoinValue, setAutoJoinValue] = useState<string | null | undefined>(undefined);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!wantsAutoJoin || autoJoinValue !== undefined || !storage) return;
|
|
142
|
+
let cancelled = false;
|
|
143
|
+
// Ceiling on the read, the same discipline as `loadPersistedSession`: a hung storage adapter
|
|
144
|
+
// must degrade to "no auto key", never to a stuck loader over the host's onboarding. Giving up
|
|
145
|
+
// is deliberately NOT "fall back to the synchronous mint" — that key is per-launch, which is
|
|
146
|
+
// the corruption this feature is gated on `storage` to avoid.
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
if (!cancelled) setAutoJoinValue(null);
|
|
149
|
+
}, AUTO_JOIN_HYDRATION_TIMEOUT_MS);
|
|
150
|
+
void hydrateAutoDeviceKey({ appId: config.appId, storage }).then((key) => {
|
|
151
|
+
if (cancelled) return;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
setAutoJoinValue(key || null);
|
|
154
|
+
});
|
|
155
|
+
return () => {
|
|
156
|
+
cancelled = true;
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
};
|
|
159
|
+
}, [wantsAutoJoin, autoJoinValue, config.appId, storage]);
|
|
160
|
+
|
|
161
|
+
const injectedJoinKey = wantsAutoJoin && typeof autoJoinValue === "string" ? autoJoinValue : undefined;
|
|
162
|
+
const autoJoinPending = wantsAutoJoin && autoJoinValue === undefined;
|
|
163
|
+
|
|
164
|
+
// What actually goes on the wire: the host's context, with the auto join key merged in ONLY when
|
|
165
|
+
// the host left the slot empty. Every other key the host passed survives untouched, and a host
|
|
166
|
+
// that passed a valid `device_key` gets a byte-identical payload to 0.12.1.
|
|
167
|
+
const effectiveUserContext = useMemo<Record<string, string | number | boolean> | undefined>(
|
|
168
|
+
() =>
|
|
169
|
+
injectedJoinKey
|
|
170
|
+
? { ...userContextStable, ...activationJoinContext(injectedJoinKey) }
|
|
171
|
+
: userContextStable,
|
|
172
|
+
[userContextStable, injectedJoinKey],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Dev-only warning (never a throw, never a wire change), once per mount, naming the exact fix.
|
|
176
|
+
// Reconciled with auto-join: it fires only when the gap is still OPEN — injection was impossible
|
|
177
|
+
// (no `storage`), the host opted out, or the read gave up. When auto-join covered the gap the
|
|
178
|
+
// session IS joined, so there is nothing to warn about; while it is still in flight, nothing is
|
|
179
|
+
// decided yet, so it stays quiet.
|
|
180
|
+
const warnMissingJoinKey = missingJoinKey && !injectedJoinKey && !autoJoinPending;
|
|
181
|
+
const autoJoinReason =
|
|
182
|
+
autoJoinKey === false
|
|
183
|
+
? "you passed autoJoinKey={false}."
|
|
184
|
+
: !storage
|
|
185
|
+
? "it got no `storage` prop, and without persistence its key would be different on every " +
|
|
186
|
+
"launch, which corrupts min_sessions instead of merely leaving the join empty."
|
|
187
|
+
: "your `storage` adapter did not answer in time.";
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!warnMissingJoinKey) return;
|
|
190
|
+
warnInDev(
|
|
191
|
+
"[wireai] <WireOnboarding> got no user_context.device_key, so this onboarding session can " +
|
|
192
|
+
"never be joined to the app's later events and the `activated` funnel will read zero. Pass " +
|
|
193
|
+
"userContext={activationJoinContext(deviceKey)} — and if your app owns no device id, " +
|
|
194
|
+
"userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))} returns the " +
|
|
195
|
+
"SAME id the analytics side stamps. Never hand-write userContext={{ deviceKey }}. " +
|
|
196
|
+
"The kit did NOT auto-inject its own key here because " +
|
|
197
|
+
autoJoinReason,
|
|
198
|
+
);
|
|
199
|
+
}, [warnMissingJoinKey, autoJoinReason]);
|
|
200
|
+
|
|
201
|
+
// The one case auto-join would otherwise SILENCE and must not: the host hand-wrote
|
|
202
|
+
// `userContext={{ deviceKey }}`. That bucket is not the wire key, so the kit injects its own —
|
|
203
|
+
// but the host demonstrably owns an id, and their app-side events carry THAT one, so the two
|
|
204
|
+
// families still land in disjoint id spaces. Injection makes the onboarding side self-consistent;
|
|
205
|
+
// only the host can make it join.
|
|
206
|
+
const misspelledJoinKey =
|
|
207
|
+
missingJoinKey &&
|
|
208
|
+
typeof userContextStable?.deviceKey === "string" &&
|
|
209
|
+
Boolean(userContextStable.deviceKey.trim());
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (!misspelledJoinKey || !injectedJoinKey) return;
|
|
212
|
+
warnInDev(
|
|
213
|
+
"[wireai] <WireOnboarding> got userContext={{ deviceKey }}, which is NOT the wire key — the " +
|
|
214
|
+
"server's device lookup reads `device_key`. The kit auto-injected its own device_key so this " +
|
|
215
|
+
"session is at least self-consistent, but your app's own events carry YOUR id, so the two " +
|
|
216
|
+
"still will not join and the `activated` funnel stays zero. Pass " +
|
|
217
|
+
"userContext={activationJoinContext(deviceKey)} instead.",
|
|
218
|
+
);
|
|
219
|
+
}, [misspelledJoinKey, injectedJoinKey]);
|
|
220
|
+
|
|
90
221
|
// The context payload shared by the A2A metadata + client-event paths. `userContext` +
|
|
91
222
|
// `userId` are host-injected and forwarded verbatim (the server sanitizes them). Omitted keys
|
|
92
223
|
// stay absent, so `dropped`/`client_fallback` events carry the user id when it's known.
|
|
93
224
|
const clientContext = useMemo(
|
|
94
|
-
() => ({ device, userContext:
|
|
95
|
-
[device,
|
|
225
|
+
() => ({ device, userContext: effectiveUserContext, userId: boundUserId }),
|
|
226
|
+
[device, effectiveUserContext, boundUserId],
|
|
96
227
|
);
|
|
97
228
|
// One stable session id per onboarding SESSION (not per mount). It's used as the
|
|
98
229
|
// client event `session_id` AND forwarded to the backend as `metadata.sessionId` so
|
|
@@ -180,7 +311,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
180
311
|
// Device snapshot + host-injected user context ride the session-start metadata so the
|
|
181
312
|
// backend can segment the funnel. Old servers ignore these unknown keys (backward compat).
|
|
182
313
|
device,
|
|
183
|
-
...(
|
|
314
|
+
...(effectiveUserContext ? { userContext: effectiveUserContext } : {}),
|
|
184
315
|
// The session-start user id (from the ref) rides the session-start metadata so the
|
|
185
316
|
// server binds the session to a real user at creation. Reading the ref — not
|
|
186
317
|
// `boundUserId` — keeps this memo off the userId dependency, so a mid-session change
|
|
@@ -189,7 +320,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
189
320
|
},
|
|
190
321
|
timeoutMs: 60_000,
|
|
191
322
|
};
|
|
192
|
-
}, [config.serverUrl, config.appId, config.apiKey, metadataStable, sessionId, componentsStable, device,
|
|
323
|
+
}, [config.serverUrl, config.appId, config.apiKey, metadataStable, sessionId, componentsStable, device, effectiveUserContext]);
|
|
193
324
|
|
|
194
325
|
// Where client-reported events are POSTed (`/v1/events`). Same tenant creds as the flow.
|
|
195
326
|
const reportTarget = useMemo<ClientEventTarget>(
|
|
@@ -228,7 +359,11 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
228
359
|
// the provider with a throwaway id and swapping later would re-init it mid-flight.
|
|
229
360
|
// The read is timeout-capped in loadPersistedSession, so this gate is one storage
|
|
230
361
|
// read (~a frame), never an indefinite hold.
|
|
231
|
-
|
|
362
|
+
//
|
|
363
|
+
// The auto join key rides the SAME gate for the same reason (`effectiveUserContext` is also an
|
|
364
|
+
// `llm` dependency). It costs no extra hold in practice: auto-join requires `storage`, and with
|
|
365
|
+
// `storage` this gate is already closed on the persisted-session read.
|
|
366
|
+
if (!session || autoJoinPending) {
|
|
232
367
|
return (
|
|
233
368
|
<OnboardingThemeProvider theme={theme}>
|
|
234
369
|
<LoadingScreen
|
|
@@ -59,7 +59,18 @@ export const useWireActivation = (config: WireActivationConfig): UseWireActivati
|
|
|
59
59
|
const ref = useRef<WireActivation | undefined>(undefined);
|
|
60
60
|
const prevKeys = useRef<string>("");
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// The identity of the built instance. `userContext.deviceKey` MUST be in here: `createWireActivation`
|
|
63
|
+
// honors it as an explicit device key (`config.deviceKey ?? config.userContext?.deviceKey`), so a host
|
|
64
|
+
// that hydrates its device id asynchronously and passes it only inside `userContext` would otherwise
|
|
65
|
+
// keep an instance frozen on the first render's `undefined` — the stale-static-ref failure this repo
|
|
66
|
+
// has already been bitten by (see .memory/70-knowledge.md, "Stale Option Closures in Static Refs").
|
|
67
|
+
const currentKeys = [
|
|
68
|
+
config.serverUrl,
|
|
69
|
+
config.apiKey,
|
|
70
|
+
config.appId,
|
|
71
|
+
config.deviceKey,
|
|
72
|
+
config.userContext?.deviceKey,
|
|
73
|
+
].join("|");
|
|
63
74
|
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
64
75
|
prevKeys.current = currentKeys;
|
|
65
76
|
ref.current = createWireActivation(config);
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* await wire.track("journal_done"); // awaitable POST + auto-revalidate
|
|
13
13
|
*
|
|
14
14
|
* `track` POSTs `event_type='app_event'`, `question_key=<name>` (the EXACT string a review /
|
|
15
|
-
* questionnaire firing TRIGGER matches on) under the CURRENT `
|
|
15
|
+
* questionnaire firing TRIGGER matches on) under the CURRENT `ensureCurrentSessionId()` — the same id
|
|
16
16
|
* the gates pass to their `/decision` fetch, so the server's session-scoped trigger rule agrees —
|
|
17
17
|
* with `user_context.device_key` for the min-sessions / arm-assignment lookups. On a successful POST
|
|
18
18
|
* it bumps decision revalidation so a subscribed gate re-fetches and can fire.
|
|
@@ -22,14 +22,15 @@
|
|
|
22
22
|
* and `getCurrentSessionId` — it introduces NO second session concept and duplicates no POST path.
|
|
23
23
|
* React-free (the optional React glue is the thin `useWireActivation` hook).
|
|
24
24
|
*/
|
|
25
|
-
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
25
|
+
import { ensureCurrentSessionId, getCurrentSessionId } from "../analytics/currentSession";
|
|
26
26
|
import {
|
|
27
27
|
reportClientEventAwait,
|
|
28
28
|
type ClientEvent,
|
|
29
29
|
type ClientEventTarget,
|
|
30
30
|
} from "../analytics/reportClientEvent";
|
|
31
|
-
import {
|
|
31
|
+
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
32
32
|
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
33
|
+
import { detectAppVersion } from "../device/appVersion";
|
|
33
34
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
35
|
import {
|
|
35
36
|
bumpActivationRevalidation,
|
|
@@ -78,8 +79,14 @@ export type WireActivation = {
|
|
|
78
79
|
/**
|
|
79
80
|
* Awaitable action report: POST `event_type='app_event'`, `question_key=<name>`, optional `meta`,
|
|
80
81
|
* under the CURRENT session id + `user_context.device_key`. Resolves `true` once the server has
|
|
81
|
-
* stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump)
|
|
82
|
-
*
|
|
82
|
+
* stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump) for a blank
|
|
83
|
+
* name or a failed POST. Never throws.
|
|
84
|
+
*
|
|
85
|
+
* It no longer refuses when no app-open has been registered: the session id is resolved through
|
|
86
|
+
* `ensureCurrentSessionId()`, which mints and registers one in that case (the server requires a
|
|
87
|
+
* non-empty `session_id` and silently drops an event without one, so bailing lost the action
|
|
88
|
+
* entirely). A host that fires `reportSessionStart` / `useLifecycleEvents` first is unaffected —
|
|
89
|
+
* the real per-open id is already registered and gets used exactly as before.
|
|
83
90
|
*/
|
|
84
91
|
track(name: string, meta?: Record<string, unknown>): Promise<boolean>;
|
|
85
92
|
/** The CURRENT per-open session id (the kit's canonical `getCurrentSessionId()`), or `undefined`. */
|
|
@@ -98,28 +105,35 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
98
105
|
const target: ClientEventTarget = { serverUrl: config.serverUrl, apiKey: config.apiKey };
|
|
99
106
|
|
|
100
107
|
// Device key: an explicit id (top-level or in userContext) wins and is never overwritten; otherwise
|
|
101
|
-
//
|
|
108
|
+
// read the ONE process-wide auto id (`resolveAutoDeviceKey`) so this instance and a sibling
|
|
109
|
+
// `createAnalytics` instance carry the SAME `device_key` for the same install. Minting locally here
|
|
110
|
+
// gave one install two auto ids — see the registry note in context/deviceId.ts.
|
|
102
111
|
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
|
|
113
|
+
appId: config.appId,
|
|
114
|
+
// An explicit key opts out of minting AND persisting (unchanged contract).
|
|
115
|
+
storage: explicitDeviceKey ? undefined : config.storage,
|
|
116
|
+
};
|
|
117
|
+
// Start hydration AT CONSTRUCTION (not at the first `track`) so the persisted id is adopted as early
|
|
118
|
+
// as it used to be. The return value is deliberately discarded — every event re-resolves.
|
|
119
|
+
if (!explicitDeviceKey) resolveAutoDeviceKey(autoDeviceKeyOptions);
|
|
120
|
+
|
|
121
|
+
// The auto-detected host app version, read ONCE (cheap, sync, never throws) — same as the facade.
|
|
122
|
+
// Without it this path had NO `app_version` fallback at all, so every `wire.track` event from a host
|
|
123
|
+
// that passed no `config.appVersion` (which `wireConfigFromEnv` could not even carry) shipped
|
|
124
|
+
// without one, while the facade and lifecycle families both had theirs.
|
|
125
|
+
const detectedAppVersion = detectAppVersion();
|
|
116
126
|
|
|
117
127
|
// Stamp the resolved rich context onto the event: `user_context` bucket (device_key always, plus any
|
|
118
128
|
// app_version / opt-in user_email / namespaced extra) and the top-level opaque `user_id`.
|
|
119
129
|
const applyContext = (event: ClientEvent): void => {
|
|
120
130
|
const resolved = resolveUserContext(
|
|
121
|
-
|
|
122
|
-
{
|
|
131
|
+
// `??` is lazy on purpose: an explicit key must never even touch the auto registry.
|
|
132
|
+
{
|
|
133
|
+
...(config.userContext ?? {}),
|
|
134
|
+
deviceKey: explicitDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions),
|
|
135
|
+
},
|
|
136
|
+
{ autoAppVersion: config.appVersion ?? detectedAppVersion },
|
|
123
137
|
);
|
|
124
138
|
if (resolved.userContext) {
|
|
125
139
|
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
@@ -128,10 +142,15 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
128
142
|
};
|
|
129
143
|
|
|
130
144
|
const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
if (!clean(name)
|
|
145
|
+
// A blank name is the only thing left to refuse on: there is no `question_key` to match a
|
|
146
|
+
// firing trigger against, so bail WITHOUT bumping (a bump with no posted event would only make
|
|
147
|
+
// the gate re-fetch for nothing).
|
|
148
|
+
if (!clean(name)) return false;
|
|
149
|
+
// No app-open registered is NOT a reason to drop the action. `ensureCurrentSessionId` returns
|
|
150
|
+
// the registered per-open id when there is one (the unchanged path) and otherwise mints +
|
|
151
|
+
// registers one, so the POST always carries the non-empty `session_id` the server requires
|
|
152
|
+
// instead of being accepted with a 200 and discarded.
|
|
153
|
+
const sessionId = ensureCurrentSessionId();
|
|
135
154
|
const event: ClientEvent = {
|
|
136
155
|
event_type: "app_event",
|
|
137
156
|
session_id: sessionId,
|
|
@@ -27,16 +27,17 @@
|
|
|
27
27
|
* FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
|
|
28
28
|
*/
|
|
29
29
|
import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
|
|
30
|
-
import {
|
|
30
|
+
import { ensureCurrentSessionId } from "./currentSession";
|
|
31
31
|
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
32
|
-
import {
|
|
32
|
+
import type { ClientEvent } from "./reportClientEvent";
|
|
33
33
|
import {
|
|
34
34
|
analyticsUserIdStorageKey,
|
|
35
35
|
clearPiiFromContext,
|
|
36
36
|
resolveUserContext,
|
|
37
37
|
type WireUserContext,
|
|
38
38
|
} from "../context/userContext";
|
|
39
|
-
import {
|
|
39
|
+
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
40
|
+
import { detectAppVersion } from "../device/appVersion";
|
|
40
41
|
import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
|
|
41
42
|
|
|
42
43
|
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
@@ -63,8 +64,15 @@ export type CreateAnalyticsConfig = {
|
|
|
63
64
|
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
64
65
|
apiKey: string;
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
67
|
+
* ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
|
|
68
|
+
* this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
|
|
69
|
+
* following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
|
|
70
|
+
* analytics then collapse onto a single device-scoped session — one "first open", forever.
|
|
71
|
+
*
|
|
72
|
+
* OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
|
|
73
|
+
* when no open has been registered yet it mints one AND registers it, so every later surface joins
|
|
74
|
+
* the same session instead of each inventing its own. Pass one ONLY if your host runs its own
|
|
75
|
+
* session lifecycle and owns the id the server should correlate on.
|
|
68
76
|
*/
|
|
69
77
|
sessionId?: string;
|
|
70
78
|
/** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
|
|
@@ -151,16 +159,35 @@ export const createAnalytics = (
|
|
|
151
159
|
config: CreateAnalyticsConfig,
|
|
152
160
|
options: AnalyticsOptions = {},
|
|
153
161
|
): Analytics => {
|
|
154
|
-
// A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
|
|
155
|
-
// no per-open session has been registered yet (see `resolveSessionId`).
|
|
156
|
-
const instanceSessionId = config.sessionId ?? makeSessionId();
|
|
157
|
-
|
|
158
162
|
// The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
|
|
159
163
|
// id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
|
|
160
164
|
// `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
|
|
161
|
-
// phantom session
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
// phantom session.
|
|
166
|
+
//
|
|
167
|
+
// `ensureCurrentSessionId` (not the bare `getCurrentSessionId`) is what closes the facade-first
|
|
168
|
+
// ordering hole: a `track` that runs BEFORE the root lifecycle effect used to fall back to a
|
|
169
|
+
// per-instance id this facade never REGISTERED, so the server saw a session it had no
|
|
170
|
+
// `session_started` for and back-filled a phantom one. The two paths that already mint on the wire
|
|
171
|
+
// (`wire.track`, `reportAppEvent`) both register; this one only read. Registering makes the
|
|
172
|
+
// fallback id the id every LATER surface joins on, and when an open IS registered this is exactly
|
|
173
|
+
// `getCurrentSessionId()` — so nothing changes for a host that mounts lifecycle first.
|
|
174
|
+
const resolveSessionId = (): string => config.sessionId ?? ensureCurrentSessionId();
|
|
175
|
+
|
|
176
|
+
// A frozen id is almost always a mistake (it silently flattens every open into ONE session), so
|
|
177
|
+
// name it once at construction — same dev-only channel as the email-shape guard below.
|
|
178
|
+
if (config.sessionId) {
|
|
179
|
+
warnInDev(
|
|
180
|
+
"[wireai] createAnalytics({ sessionId }) PINS every event from this instance to that one " +
|
|
181
|
+
"frozen id and opts out of the live per-open session (app.session_started) — lifecycle " +
|
|
182
|
+
"analytics collapse onto a single device-scoped id. Remove it unless your host runs its " +
|
|
183
|
+
"own session lifecycle.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The auto-detected host app version, read ONCE here (cheap, sync, never throws). It backs the
|
|
188
|
+
// `user_context.app_version` fallback below: without it a host that passes no `config.appVersion`
|
|
189
|
+
// got the detected version on `device.appVersion` only, leaving the user_context field absent.
|
|
190
|
+
const detectedAppVersion = detectAppVersion();
|
|
164
191
|
|
|
165
192
|
// The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
|
|
166
193
|
// every event so a post-mount update (login) takes effect immediately. Declared before the envelope
|
|
@@ -177,21 +204,18 @@ export const createAnalytics = (
|
|
|
177
204
|
typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
|
|
178
205
|
? config.userContext.deviceKey.trim()
|
|
179
206
|
: undefined;
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
})
|
|
193
|
-
.catch(() => {});
|
|
194
|
-
}
|
|
207
|
+
// The auto id comes from the ONE process-wide registry (`resolveAutoDeviceKey`), NOT a mint local to
|
|
208
|
+
// this instance. A host that also builds a `createWireActivation` instance used to get a SECOND,
|
|
209
|
+
// different auto id for the same install, splitting `device_key` across two id spaces — see the
|
|
210
|
+
// registry note in context/deviceId.ts. Resolved lazily per event so hydration is picked up.
|
|
211
|
+
const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
|
|
212
|
+
appId: config.appId,
|
|
213
|
+
// A host-supplied deviceKey opts out of minting AND persisting (unchanged contract).
|
|
214
|
+
storage: hostDeviceKeyAtInit ? undefined : config.storage,
|
|
215
|
+
};
|
|
216
|
+
// Start hydration AT CONSTRUCTION (not at the first event) so the persisted id is adopted as early
|
|
217
|
+
// as it used to be. The return value is deliberately discarded — every event re-resolves.
|
|
218
|
+
if (!hostDeviceKeyAtInit) resolveAutoDeviceKey(autoDeviceKeyOptions);
|
|
195
219
|
|
|
196
220
|
// A provider (not a fixed value) so `networkType`, the current session id, AND the effective app
|
|
197
221
|
// version are evaluated fresh on every enqueue. An explicit `WireUserContext.appVersion` (a host that
|
|
@@ -241,8 +265,9 @@ export const createAnalytics = (
|
|
|
241
265
|
? userContext.deviceKey
|
|
242
266
|
: undefined;
|
|
243
267
|
const resolved = resolveUserContext(
|
|
244
|
-
|
|
245
|
-
{
|
|
268
|
+
// `??` is lazy on purpose: a host-supplied key must never even touch the auto registry.
|
|
269
|
+
{ ...userContext, deviceKey: hostDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions) },
|
|
270
|
+
{ autoAppVersion: config.appVersion ?? detectedAppVersion },
|
|
246
271
|
);
|
|
247
272
|
if (resolved.userContext) {
|
|
248
273
|
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
@@ -32,7 +32,22 @@
|
|
|
32
32
|
* PROCESS-LOCAL, NOT PERSISTED: the slot lives on the runtime global, so it tracks the CURRENT
|
|
33
33
|
* process's open and a fresh open overwrites it. There is no cross-launch state.
|
|
34
34
|
* `resetCurrentSessionId` clears the slot so a unit test starts from a clean registry.
|
|
35
|
+
*
|
|
36
|
+
* ── WHY `ensureCurrentSessionId` EXISTS (the silent-drop contract) ─────────────────────────────
|
|
37
|
+
* The server's event model declares `session_id: str = Field(min_length=1)` — REQUIRED, non-empty.
|
|
38
|
+
* `POST /v1/events` validates each event inside a try/except that increments a `skipped` counter and
|
|
39
|
+
* still returns HTTP 200. So an event posted without a `session_id` is accepted by the wire and
|
|
40
|
+
* DISCARDED by the server, and a fire-and-forget client can never learn it happened. That is the
|
|
41
|
+
* worst of both: no error, no data. Screen tracking in a host that never mounted the lifecycle hook
|
|
42
|
+
* fell into exactly that hole — every screen view posted, 200'd, and dropped.
|
|
43
|
+
*
|
|
44
|
+
* `ensureCurrentSessionId` closes it: it returns the registered id when an open HAS been registered
|
|
45
|
+
* (unchanged behaviour for every host that fires `reportSessionStart` first), and otherwise mints one,
|
|
46
|
+
* REGISTERS it, and returns it — so every later event in the process correlates to that same id
|
|
47
|
+
* instead of each emitting its own orphan. A minted id is a fallback, not a substitute for a real
|
|
48
|
+
* app-open: it warns once in dev, naming the fix.
|
|
35
49
|
*/
|
|
50
|
+
import { makeSessionId } from "./reportClientEvent";
|
|
36
51
|
|
|
37
52
|
/**
|
|
38
53
|
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
@@ -67,3 +82,71 @@ export const getCurrentSessionId = (): string | undefined =>
|
|
|
67
82
|
export const resetCurrentSessionId = (): void => {
|
|
68
83
|
globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
|
|
69
84
|
};
|
|
85
|
+
|
|
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
|
+
/** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
|
|
107
|
+
const MINT_WARNING =
|
|
108
|
+
"[wireai] No app-open session was registered, so a session id was minted for this event " +
|
|
109
|
+
"(the server drops an event that has no session_id, and still answers 200). Mount " +
|
|
110
|
+
"useLifecycleEvents at your app root so events correlate to a real app-open.";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* "Have we already warned about a minted session id?" — its OWN `Symbol.for` slot, for the same
|
|
114
|
+
* cross-bundle reason as the id itself: a plain module `let` would warn once per inlined copy, i.e.
|
|
115
|
+
* once per bundle, not once per process. NOT cleared by `resetCurrentSessionId`: "warn once" is a
|
|
116
|
+
* process-lifetime promise, and a test that resets the id between mints is still one process.
|
|
117
|
+
*/
|
|
118
|
+
const MINT_WARNED_SLOT: unique symbol = Symbol.for(
|
|
119
|
+
"@wireai/activation:currentSessionIdMintWarned",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
type GlobalWithWarnSlot = typeof globalThis & { [MINT_WARNED_SLOT]?: boolean };
|
|
123
|
+
|
|
124
|
+
const warnSlot = globalThis as GlobalWithWarnSlot;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The current per-open `session_id`, MINTING and registering one when no app-open has been
|
|
128
|
+
* registered yet. Always returns a non-empty string. Idempotent (a second call returns the same id)
|
|
129
|
+
* and never throws.
|
|
130
|
+
*
|
|
131
|
+
* Use this on every path that puts a `session_id` on the wire. The server REQUIRES a non-empty
|
|
132
|
+
* `session_id` and drops the event otherwise while still answering 200 (see the module header), so
|
|
133
|
+
* "no id yet" must never mean "send it without one".
|
|
134
|
+
*
|
|
135
|
+
* BACKWARD-COMPATIBLE BY CONSTRUCTION: when `reportSessionStart` / `useLifecycleEvents` has already
|
|
136
|
+
* registered the real per-open id, this is `getCurrentSessionId()` and nothing changes. It only ever
|
|
137
|
+
* mints in the case that used to produce a silently discarded event.
|
|
138
|
+
*
|
|
139
|
+
* A mint means the host never registered an app-open, so the minted id is one the server has not
|
|
140
|
+
* seen a `session_started` for — the events land, but the session is thinner than a real open.
|
|
141
|
+
* Hence the one-time dev warning naming the fix (mount `useLifecycleEvents` at the app root).
|
|
142
|
+
*/
|
|
143
|
+
export const ensureCurrentSessionId = (): string => {
|
|
144
|
+
const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
145
|
+
if (typeof existing === "string" && existing.length > 0) return existing;
|
|
146
|
+
const minted = makeSessionId();
|
|
147
|
+
globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
|
|
148
|
+
if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
|
|
149
|
+
warnSlot[MINT_WARNED_SLOT] = true;
|
|
150
|
+
}
|
|
151
|
+
return minted;
|
|
152
|
+
};
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
* `notifyOnline()`; persistence is the host-injected AsyncStorage-compatible subset.
|
|
25
25
|
*/
|
|
26
26
|
import type { ContextEnvelope } from "./contextEnvelope";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
buildEventsRequest,
|
|
29
|
+
warnOnSkippedEvents,
|
|
30
|
+
type ClientEvent,
|
|
31
|
+
type ClientEventTarget,
|
|
32
|
+
} from "./reportClientEvent";
|
|
28
33
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
29
34
|
|
|
30
35
|
/** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
|
|
@@ -241,6 +246,9 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
241
246
|
const timer = setTimeout(() => controller?.abort(), 15_000);
|
|
242
247
|
try {
|
|
243
248
|
const res = await fetch(req.url, { ...req.init, signal: controller?.signal });
|
|
249
|
+
// A 200 can still carry `skipped:N` — events the server threw away. Log-only: the ack below
|
|
250
|
+
// stays `res.ok`, so retry/dequeue behaviour is unchanged.
|
|
251
|
+
warnOnSkippedEvents(res);
|
|
244
252
|
return !!(res && (res as { ok?: boolean }).ok);
|
|
245
253
|
} catch {
|
|
246
254
|
return false;
|
package/src/analytics/index.ts
CHANGED
|
@@ -73,5 +73,24 @@ export {
|
|
|
73
73
|
export type { ClearUserContextOptions } from "../context/userContext";
|
|
74
74
|
export { looksLikeEmail } from "../identity/userIdentity";
|
|
75
75
|
|
|
76
|
+
// ─── The ONE auto-minted, persisted per-install `device_key` (the join key) ────
|
|
77
|
+
// Public so an analytics-only consumer can read the SAME id the façade stamps and forward it to the
|
|
78
|
+
// onboarding side via `activationJoinContext(deviceKey)`. Without it the join has no reachable key.
|
|
79
|
+
export {
|
|
80
|
+
resolveAutoDeviceKey,
|
|
81
|
+
resetAutoDeviceKeys,
|
|
82
|
+
deviceIdStorageKey,
|
|
83
|
+
AUTO_DEVICE_ID_PREFIX,
|
|
84
|
+
} from "../context/deviceId";
|
|
85
|
+
export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
86
|
+
|
|
76
87
|
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
77
|
-
export {
|
|
88
|
+
export {
|
|
89
|
+
getCurrentSessionId,
|
|
90
|
+
// The write-through read every wire path uses: returns the registered per-open id, minting +
|
|
91
|
+
// registering one when no app-open has been registered (the server drops an event with no
|
|
92
|
+
// `session_id` and still answers 200).
|
|
93
|
+
ensureCurrentSessionId,
|
|
94
|
+
setCurrentSessionId,
|
|
95
|
+
resetCurrentSessionId,
|
|
96
|
+
} from "./currentSession";
|