@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.
Files changed (73) hide show
  1. package/AGENTS.md +62 -8
  2. package/CHANGELOG.md +188 -3
  3. package/INTEGRATION_PROMPT.md +25 -2
  4. package/README.md +112 -1
  5. package/dist/analytics/index.d.mts +18 -6
  6. package/dist/analytics/index.d.ts +18 -6
  7. package/dist/analytics/index.js +151 -41
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +147 -42
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +16 -0
  12. package/dist/coachmarks/index.d.ts +16 -0
  13. package/dist/coachmarks/index.js +19 -13
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +19 -13
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-C0_odnIW.d.mts → currentSession-BlCeDP0f.d.mts} +145 -33
  18. package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-BxEB37xt.d.ts} +145 -33
  19. package/dist/index.d.mts +243 -53
  20. package/dist/index.d.ts +243 -53
  21. package/dist/index.js +531 -156
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +517 -157
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +1 -1
  26. package/dist/questionnaire/index.d.ts +1 -1
  27. package/dist/questionnaire/index.js +79 -14
  28. package/dist/questionnaire/index.js.map +1 -1
  29. package/dist/questionnaire/index.mjs +79 -14
  30. package/dist/questionnaire/index.mjs.map +1 -1
  31. package/dist/reviews/index.d.mts +2 -2
  32. package/dist/reviews/index.d.ts +2 -2
  33. package/dist/reviews/index.js +103 -16
  34. package/dist/reviews/index.js.map +1 -1
  35. package/dist/reviews/index.mjs +103 -16
  36. package/dist/reviews/index.mjs.map +1 -1
  37. package/dist/showcase/index.js +15 -6
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs +15 -6
  40. package/dist/showcase/index.mjs.map +1 -1
  41. package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
  42. package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
  43. package/llms.txt +3 -0
  44. package/package.json +1 -1
  45. package/src/WireOnboarding.tsx +140 -5
  46. package/src/activation/useWireActivation.ts +12 -1
  47. package/src/activation/wireActivation.ts +44 -25
  48. package/src/analytics/analyticsFacade.ts +54 -29
  49. package/src/analytics/currentSession.ts +83 -0
  50. package/src/analytics/eventQueue.ts +9 -1
  51. package/src/analytics/index.ts +20 -1
  52. package/src/analytics/reportClientEvent.ts +42 -0
  53. package/src/analytics/screenTracking.ts +6 -1
  54. package/src/analytics/useAnalytics.ts +22 -1
  55. package/src/coachmarks/runtime.ts +53 -17
  56. package/src/config/wireConfigFromEnv.ts +46 -2
  57. package/src/context/deviceId.ts +173 -0
  58. package/src/context/userContext.ts +18 -0
  59. package/src/index.ts +54 -2
  60. package/src/questionnaire/runtime.ts +13 -2
  61. package/src/questionnaire/useQuestionnaireGate.ts +11 -4
  62. package/src/revenuecat/index.ts +55 -0
  63. package/src/revenuecat/purchaseEvents.ts +167 -0
  64. package/src/revenuecat/revenueCatBridge.ts +221 -0
  65. package/src/revenuecat/types.ts +95 -0
  66. package/src/reviews/runtime.ts +153 -1
  67. package/src/reviews/transport.ts +21 -2
  68. package/src/reviews/useReviewGate.ts +14 -4
  69. package/src/session-analytics/lifecycle.ts +9 -2
  70. package/src/session-analytics/reportSessionStart.ts +15 -4
  71. package/src/session-analytics/useLifecycleEvents.ts +83 -27
  72. package/src/session-analytics/useSessionStart.ts +37 -1
  73. package/src/types.ts +41 -4
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import type { CoachmarkStorage } from "../coachmarks/types";
8
8
  import { getCoachmarkStorage } from "../coachmarks/runtime";
9
+ import { getCurrentSessionId } from "../analytics/currentSession";
10
+ import { makeSessionId } from "../analytics/reportClientEvent";
9
11
 
10
12
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
11
13
  export const reviewSeenKey = (id: string, version?: string): string =>
@@ -14,14 +16,59 @@ export const reviewSeenKey = (id: string, version?: string): string =>
14
16
  /** Last-shown timestamp key (epoch ms), for the cooldown rule. */
15
17
  export const reviewLastShownKey = (id: string): string => `wire_review_${id}_last`;
16
18
 
17
- /** Session-count key, incremented once per gate mount, for the min-sessions rule. */
19
+ /** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
18
20
  export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sessions`;
19
21
 
22
+ /** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
23
+ export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
24
+
20
25
  /** Resolve the storage to use: an explicit override, else the provider-injected singleton. */
21
26
  export const resolveStorage = (
22
27
  override?: CoachmarkStorage | null,
23
28
  ): CoachmarkStorage | null => override ?? getCoachmarkStorage();
24
29
 
30
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnMissingGateStorage}. */
31
+ declare const __DEV__: boolean | undefined;
32
+
33
+ /** Once-per-process latch on its own `Symbol.for` slot: a plain `let` would warn once per inlined
34
+ * bundle copy, and the gates live in three of them. */
35
+ const NO_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
36
+ "@wireai/activation:gateStorageWarned",
37
+ );
38
+
39
+ type GlobalWithGateWarn = typeof globalThis & { [NO_STORAGE_WARNED_SLOT]?: boolean };
40
+
41
+ const gateWarnGlobal = globalThis as GlobalWithGateWarn;
42
+
43
+ /**
44
+ * Warn once, in dev builds only, when a gate has NO sync storage to count with.
45
+ *
46
+ * With no storage `readInt` returns 0, so `bumpSessionCount` answers `1` on every call forever and
47
+ * the fail-closed `minSessions: 2` default can never be met: the gate is pinned shut for the life of
48
+ * the app and nothing anywhere says so. The fix is one line at the app root (mount `CoachmarkProvider`
49
+ * with a sync storage, or pass `storage` to the gate), so name it.
50
+ */
51
+ export const warnMissingGateStorage = (
52
+ storage: CoachmarkStorage | null,
53
+ gate: "review" | "questionnaire",
54
+ ): void => {
55
+ if (storage) return;
56
+ if (gateWarnGlobal[NO_STORAGE_WARNED_SLOT]) return;
57
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
58
+ if (typeof console === "undefined" || !console.warn) return;
59
+ gateWarnGlobal[NO_STORAGE_WARNED_SLOT] = true;
60
+ console.warn(
61
+ `[wireai] the ${gate} gate has no sync storage, so its app-open counter is stuck at 1 and the ` +
62
+ "fail-closed minSessions rule can never be satisfied — the gate will never fire. Mount " +
63
+ "CoachmarkProvider with a sync storage adapter at your app root, or pass `storage` to the gate.",
64
+ );
65
+ };
66
+
67
+ /** Test-only: forget the once-per-process no-storage warn latch. */
68
+ export const resetGateStorageWarning = (): void => {
69
+ gateWarnGlobal[NO_STORAGE_WARNED_SLOT] = undefined;
70
+ };
71
+
25
72
  /** Read an integer from sync storage (0 on a missing/unparseable/throwing read). */
26
73
  export const readInt = (storage: CoachmarkStorage | null, key: string): number => {
27
74
  if (!storage) return 0;
@@ -43,3 +90,108 @@ export const writeInt = (storage: CoachmarkStorage | null, key: string, value: n
43
90
  // Best-effort.
44
91
  }
45
92
  };
93
+
94
+ /** Read a string from sync storage (undefined on a missing/blank/throwing read). */
95
+ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefined => {
96
+ if (!storage) return undefined;
97
+ try {
98
+ const raw = storage.getItem(key);
99
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ };
104
+
105
+ // ── The "which app-open is this" id ──────────────────────────────────────────────────────────
106
+ //
107
+ // The gates' `minSessions` rule needs a SESSION, and the counter behind it used to increment once
108
+ // per gate MOUNT. A mount is not a session: navigating away from the home feed and back, a tab that
109
+ // unmounts its screen, or React StrictMode's dev double-invoke of the `useState` initializer all
110
+ // bumped it. So `minSessions: 2` — the fail-closed default added after the 2026-07-16 one-star
111
+ // incident — was satisfiable inside the user's FIRST app open, which is the exact thing it exists
112
+ // to prevent. `sessions` was a count of mounts wearing the name of a session.
113
+ //
114
+ // The correct unit already exists: `getCurrentSessionId()`, the per-open id the server sees on
115
+ // `app.session_started`. When a host wires the lifecycle events, the counter keys off THAT and is
116
+ // exactly "distinct app-opens", the same unit the server's own `min_sessions` uses.
117
+ //
118
+ // When no host wired lifecycle events there is no registered open, so we fall back to a PROCESS-
119
+ // scoped id: one JS process is one app launch, which is still a genuine app-open and is strictly
120
+ // closer to a session than a mount is. It lives in a `globalThis` slot keyed by `Symbol.for(...)`
121
+ // for the same reason `currentSession` does: tsup inlines this module into several bundles and a
122
+ // plain module-local `let` would give each bundle its own "process".
123
+ //
124
+ // ── WHY THE UNIT IS PINNED FOR THE WHOLE LAUNCH ──────────────────────────────────────────────
125
+ // The first shape of this function read the two tiers LIVE on every call: the registered session id
126
+ // when there was one, else the process id. That let the UNIT change mid-launch, and React's own
127
+ // ordering guarantees it does. The gates call `bumpSessionCount` from a `useState` INITIALIZER,
128
+ // which runs during render; `useLifecycleEvents` registers the session id from a root EFFECT; and
129
+ // React runs every render before any effect. So a cold start went:
130
+ //
131
+ // render: gate initializer → no session registered yet → PROCESS id → count = 1, open = <process>
132
+ // effect: root lifecycle → setCurrentSessionId(<session id>)
133
+ // remount: gate initializer → SESSION id ≠ the stored open → count = 2
134
+ //
135
+ // Two "sessions" inside one app open, which makes the fail-closed `minSessions: 2` default (added
136
+ // after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard. So the
137
+ // FIRST read pins whatever it resolved into the process slot and every later read returns that,
138
+ // regardless of what the session registry does afterwards. The client counter only has to be
139
+ // monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
140
+ // events, so nothing downstream needs the two ids to be identical.
141
+ const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
142
+
143
+ type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
144
+
145
+ const openIdGlobal = globalThis as GlobalWithOpenId;
146
+
147
+ /**
148
+ * The id identifying THIS app-open for gate counting, PINNED on first read for the whole launch:
149
+ * the live per-open `session_id` if one was already registered when the first gate asked, else a
150
+ * minted per-process id. Stable from the first render to the last. Never empty.
151
+ */
152
+ export const currentOpenId = (): string => {
153
+ const existing = openIdGlobal[PROCESS_OPEN_ID_SLOT];
154
+ if (existing) return existing;
155
+ // Adopt the registered session id when the host wired lifecycle BEFORE any gate rendered; that is
156
+ // the same unit the server counts. Otherwise mint one. Either way it is pinned from here on.
157
+ const resolved = getCurrentSessionId() ?? makeSessionId();
158
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = resolved;
159
+ return resolved;
160
+ };
161
+
162
+ /** Test-only: forget the process open id so a unit test starts from a clean launch. */
163
+ export const resetProcessOpenId = (): void => {
164
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
165
+ };
166
+
167
+ /**
168
+ * Return the app-open count for the gate, incrementing it AT MOST ONCE per app-open.
169
+ *
170
+ * IDEMPOTENT by construction: the open id that last incremented the counter is stored alongside it,
171
+ * so a second call within the same open (a remount, a StrictMode double-invoke, a second gate render)
172
+ * reads the stored count back instead of bumping it. A new open id bumps exactly once.
173
+ *
174
+ * Storage-less hosts get `1` for the first call and `1` for every later call within the open —
175
+ * degraded but never inflating, which is the safe direction for a fail-closed gate.
176
+ */
177
+ export const bumpSessionCount = (
178
+ storage: CoachmarkStorage | null,
179
+ sessionsKey: string,
180
+ openKey: string,
181
+ openId: string = currentOpenId(),
182
+ ): number => {
183
+ const lastOpen = readStr(storage, openKey);
184
+ const stored = readInt(storage, sessionsKey);
185
+ // Already counted this open → return what we counted, do not bump again.
186
+ if (lastOpen === openId) return stored > 0 ? stored : 1;
187
+ const next = stored + 1;
188
+ writeInt(storage, sessionsKey, next);
189
+ if (storage) {
190
+ try {
191
+ storage.setItem(openKey, openId);
192
+ } catch {
193
+ // Best-effort: a failed write only means this open may be counted twice.
194
+ }
195
+ }
196
+ return next;
197
+ };
@@ -12,6 +12,7 @@
12
12
  * question_key=<name>), which is what the backend review-firing rules evaluate on — and
13
13
  * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
14
14
  */
15
+ import { ensureCurrentSessionId } from "../analytics/currentSession";
15
16
  import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
16
17
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
17
18
 
@@ -157,7 +158,11 @@ export const fetchReviewDecision = async (
157
158
 
158
159
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
159
160
  export interface ReportAppEventOptions {
160
- /** The onboarding/session id to correlate with, when known. */
161
+ /**
162
+ * The onboarding/session id to correlate with, when known. Optional: when omitted the event
163
+ * still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
164
+ * with no `session_id` is dropped server-side behind a 200. Pass one only to override.
165
+ */
161
166
  sessionId?: string;
162
167
  /** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
163
168
  deviceKey?: string;
@@ -171,6 +176,16 @@ export interface ReportAppEventOptions {
171
176
  * stable identifier and `meta` small + non-PII.
172
177
  *
173
178
  * reportAppEvent(target, "content_share", { sessionId, deviceKey });
179
+ *
180
+ * ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
181
+ * The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
182
+ * validates per event inside a try/except that counts the failure as `skipped` and STILL returns
183
+ * HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
184
+ * fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
185
+ * `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
186
+ * posted every screen view into that hole. So the id is no longer conditional — an explicit
187
+ * `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
188
+ * has been registered yet).
174
189
  */
175
190
  export const reportAppEvent = (
176
191
  target: ReviewTarget | undefined,
@@ -179,11 +194,15 @@ export const reportAppEvent = (
179
194
  ): void => {
180
195
  if (!target?.serverUrl || !name) return;
181
196
  try {
197
+ // An explicit id wins; a missing OR BLANK one falls back to the current per-open id. A bare
198
+ // `??` would let `sessionId: ""` through, and the server rejects an empty string exactly like
199
+ // a missing key (`min_length=1`), so the blank case has to fall back too.
200
+ const supplied = options.sessionId ?? "";
182
201
  const event: Record<string, unknown> = {
183
202
  event_type: "app_event",
184
203
  question_key: name,
204
+ session_id: supplied.trim().length > 0 ? supplied : ensureCurrentSessionId(),
185
205
  };
186
- if (options.sessionId) event.session_id = options.sessionId;
187
206
  // device_key rides in the non-PII user_context bucket the server sanitizes; the
188
207
  // review-decision endpoint reads it to group a device's sessions.
189
208
  if (options.deviceKey) event.user_context = { device_key: options.deviceKey };
@@ -19,11 +19,14 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
19
19
  import { decideReview, evaluateGate, resolveRules } from "./decision";
20
20
  import { sameDecision, shallowEqual } from "./equality";
21
21
  import {
22
+ bumpSessionCount,
22
23
  readInt,
23
24
  resolveStorage,
24
25
  reviewLastShownKey,
25
26
  reviewSeenKey,
27
+ reviewSessionOpenKey,
26
28
  reviewSessionsKey,
29
+ warnMissingGateStorage,
27
30
  writeInt,
28
31
  } from "./runtime";
29
32
  import type {
@@ -70,13 +73,20 @@ export const useReviewGate = ({
70
73
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
71
74
  const lastKey = reviewLastShownKey(config.id);
72
75
  const sessionsKey = reviewSessionsKey(config.id);
76
+ const sessionOpenKey = reviewSessionOpenKey(config.id);
73
77
 
74
- // Read (and bump) the session counter ONCE per mount: this mount is a new session.
78
+ // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
79
+ // keys off the live per-open session id (or a per-process id when no host wired the lifecycle
80
+ // events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
81
+ // initializer all read the same number back instead of inflating it. Before this, `sessions`
82
+ // counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
83
+ // very first app open — the exact scenario it was added to prevent.
75
84
  const sessions = useState(() => {
76
85
  const store = resolveStorage(storage);
77
- const next = readInt(store, sessionsKey) + 1;
78
- writeInt(store, sessionsKey, next);
79
- return next;
86
+ // No storage pins the counter at 1 forever, so the fail-closed minSessions rule can never be
87
+ // met and the gate silently never fires. Dev-only, once per process.
88
+ warnMissingGateStorage(store, "review");
89
+ return bumpSessionCount(store, sessionsKey, sessionOpenKey);
80
90
  })[0];
81
91
 
82
92
  // Gate the local rules behind an optional client-side timeout, so a reachable server
@@ -212,11 +212,18 @@ export interface WireLifecycleOptions extends ReportFirstOpenOptions {}
212
212
  * Route both through the same `sink` (the offline queue) to buffer them. Fire-and-forget.
213
213
  */
214
214
  export const wireLifecycleEvents = (opts: WireLifecycleOptions): void => {
215
- reportFirstOpen(opts);
215
+ // ONE per-open id for BOTH events when the caller supplies none. Without this, `reportFirstOpen`
216
+ // fell through to `buildLifecycleEvent`'s own `makeSessionId()` while `reportSessionStart` minted a
217
+ // DIFFERENT one — so `app.first_open` carried a session id the server never saw a `session_started`
218
+ // for and back-filled into a phantom session, inflating session counts. `useLifecycleEvents` already
219
+ // pinned this (it mints `mountOpenSessionId`); the React-free entry point did not, so the identical
220
+ // bug was still reachable from the documented non-hook path.
221
+ const sessionId = opts.sessionId ?? makeSessionId();
222
+ reportFirstOpen({ ...opts, sessionId });
216
223
  reportSessionStart({
217
224
  target: opts.target,
218
225
  sink: opts.sink,
219
- sessionId: opts.sessionId,
226
+ sessionId,
220
227
  userId: opts.userId,
221
228
  deviceKey: opts.deviceKey,
222
229
  sessionCount: opts.sessionCount,
@@ -32,7 +32,12 @@
32
32
  * never be able to break the app.
33
33
  */
34
34
  import { setCurrentSessionId } from "../analytics/currentSession";
35
- import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
+ import {
36
+ makeSessionId,
37
+ warnOnSkippedEvents,
38
+ type ClientEvent,
39
+ type ClientEventTarget,
40
+ } from "../analytics/reportClientEvent";
36
41
  import type { DeviceContext } from "../device/deviceContext";
37
42
  import { sanitizeUserId } from "../identity/userIdentity";
38
43
 
@@ -162,9 +167,15 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
162
167
  method: "POST",
163
168
  headers,
164
169
  body: JSON.stringify({ events: [event] }),
165
- }).catch(() => {
166
- // Network/transport error — analytics is best-effort, swallow.
167
- });
170
+ })
171
+ .then((res) => {
172
+ // A 200 can still carry `skipped:N` — the server took the request and threw the event away.
173
+ // Log-only; this path has nothing to retry either way.
174
+ warnOnSkippedEvents(res);
175
+ })
176
+ .catch(() => {
177
+ // Network/transport error — analytics is best-effort, swallow.
178
+ });
168
179
  } catch {
169
180
  // URL construction, JSON serialization, a throwing sink, or a missing fetch — swallow.
170
181
  }
@@ -10,7 +10,10 @@
10
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
- * too (once ever, gated by the persisted flag in `reportFirstOpen`).
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 mount fire waits on
15
+ * one storage read so both events carry the PERSISTED key, not a freshly minted one — see the
16
+ * `hydrateAutoDeviceKey` note below.
14
17
  * • ON FOREGROUND after a real background of at least {@link BACKGROUND_SESSION_MS} (30 min) — a
15
18
  * new app-open, so a fresh `app.session_started` fires. A quick app-switch does NOT count.
16
19
  *
@@ -29,6 +32,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
29
32
 
30
33
  import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
31
34
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
+ import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
32
36
  import { collectDeviceContext } from "../device/deviceContext";
33
37
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
38
  import { reportFirstOpen } from "./lifecycle";
@@ -114,6 +118,31 @@ export const useLifecycleEvents = (
114
118
  const targetOf = (cfg: LifecycleConfig | undefined): ClientEventTarget | undefined =>
115
119
  cfg?.serverUrl ? { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" } : undefined;
116
120
 
121
+ /**
122
+ * The `device_key` these lifecycle events ride under. A host-supplied id always wins.
123
+ *
124
+ * WHY THE FALLBACK EXISTS: `min_sessions` (the review / questionnaire firing rule, and the
125
+ * "fire on the user's Nth session" recipe in the README) is computed SERVER-SIDE by counting
126
+ * distinct `app.session_started` events grouped by `user_context.device_key`. A host that took
127
+ * the batteries-included path and passed no `deviceKey` emitted those events with NO device key
128
+ * at all — while its `createAnalytics` / `createWireActivation` events carried an auto-minted
129
+ * one. Two disjoint identity spaces again: the counter the rule reads could never increase, so
130
+ * `min_sessions` was structurally unsatisfiable and the gate never fired from the server side.
131
+ *
132
+ * ONLY WITH `storage`: the auto id is per-INSTALL only when it can be persisted. With no
133
+ * storage it would be per-LAUNCH, which would make every open look like a brand-new device and
134
+ * corrupt `min_sessions` in the other direction. So no storage → no fallback, same as before.
135
+ */
136
+ const resolveDeviceKey = (
137
+ cfg: LifecycleConfig | undefined,
138
+ opts: UseLifecycleEventsOptions,
139
+ ): string | undefined => {
140
+ const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
141
+ if (host) return host;
142
+ if (!cfg?.storage) return undefined;
143
+ return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
144
+ };
145
+
117
146
  // ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
118
147
  // WHY: `first_open` used to default to its OWN fresh `makeSessionId()` (via `buildLifecycleEvent`)
119
148
  // while `session_started` minted a different one — so every install's `first_open` carried a
@@ -138,7 +167,7 @@ export const useLifecycleEvents = (
138
167
  sink: resolveSink(),
139
168
  sessionId,
140
169
  userId: opts.userId,
141
- deviceKey: opts.deviceKey,
170
+ deviceKey: resolveDeviceKey(cfg, opts),
142
171
  sessionCount: opts.sessionCount,
143
172
  appVersion: cfg?.appVersion ?? device.appVersion,
144
173
  platform: Platform.OS,
@@ -147,33 +176,58 @@ export const useLifecycleEvents = (
147
176
  });
148
177
  };
149
178
 
150
- // 1) session_start FIRST (mount = an app-open), so the server has ingested `session_started`
151
- // for `mountOpenSessionId` before first_open references the same id.
152
- fireSession(mountOpenSessionId);
179
+ // The mount open: session_start FIRST (mount = an app-open), so the server has ingested
180
+ // `session_started` for `mountOpenSessionId` before first_open references the same id; then
181
+ // first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to the
182
+ // SAME per-open id so it is never a phantom session.
183
+ const fireMountOpen = () => {
184
+ fireSession(mountOpenSessionId);
153
185
 
154
- // 2) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to
155
- // the SAME per-open id so it is never a phantom session.
156
- {
157
186
  const { config: cfg, options: opts } = latest.current;
158
- if (opts.enabled !== false) {
159
- const device = collectDeviceContext();
160
- // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
161
- if (cfg?.appVersion) device.appVersion = cfg.appVersion;
162
- reportFirstOpen({
163
- target: targetOf(cfg),
164
- sink: resolveSink(),
165
- sessionId: mountOpenSessionId,
166
- storage: cfg?.storage,
167
- appId: cfg?.appId,
168
- userId: opts.userId,
169
- deviceKey: opts.deviceKey,
170
- sessionCount: opts.sessionCount,
171
- appVersion: cfg?.appVersion ?? device.appVersion,
172
- platform: Platform.OS,
173
- device,
174
- meta: opts.meta,
175
- });
176
- }
187
+ if (opts.enabled === false) return;
188
+ const device = collectDeviceContext();
189
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
190
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
191
+ reportFirstOpen({
192
+ target: targetOf(cfg),
193
+ sink: resolveSink(),
194
+ sessionId: mountOpenSessionId,
195
+ storage: cfg?.storage,
196
+ appId: cfg?.appId,
197
+ userId: opts.userId,
198
+ deviceKey: resolveDeviceKey(cfg, opts),
199
+ sessionCount: opts.sessionCount,
200
+ appVersion: cfg?.appVersion ?? device.appVersion,
201
+ platform: Platform.OS,
202
+ device,
203
+ meta: opts.meta,
204
+ });
205
+ };
206
+
207
+ // WAIT FOR THE PERSISTED DEVICE KEY, but ONLY when the auto fallback is the one in play.
208
+ //
209
+ // `resolveAutoDeviceKey` is synchronous by contract: it returns a freshly minted id and adopts the
210
+ // PERSISTED one a storage read later. Firing at mount therefore stamped a brand-new `wdev_*` on
211
+ // BOTH lifecycle events on every launch, while every other surface adopted the persisted id
212
+ // milliseconds afterwards. Those two events are exactly the ones the server counts `min_sessions`
213
+ // from (distinct `app.session_started` grouped by `device_key`), so the counter could never exceed
214
+ // 1 — and `first_open`, fired once ever, ended up under a key no later event shares, which breaks
215
+ // `first_open` → `activated` cohorting too. One storage read at mount buys both back.
216
+ //
217
+ // A host-supplied `deviceKey` needs no read, and with no `storage` there is nothing to read (and
218
+ // `resolveDeviceKey` deliberately does not fall back), so both keep firing synchronously at mount.
219
+ let cancelled = false;
220
+ const { config: mountCfg, options: mountOpts } = latest.current;
221
+ const hostKey =
222
+ typeof mountOpts.deviceKey === "string" && mountOpts.deviceKey.trim()
223
+ ? mountOpts.deviceKey
224
+ : undefined;
225
+ if (!hostKey && mountCfg?.storage) {
226
+ void hydrateAutoDeviceKey({ appId: mountCfg.appId, storage: mountCfg.storage }).then(() => {
227
+ if (!cancelled) fireMountOpen();
228
+ });
229
+ } else {
230
+ fireMountOpen();
177
231
  }
178
232
 
179
233
  // Foreground after a real background = a new app-open.
@@ -191,6 +245,8 @@ export const useLifecycleEvents = (
191
245
  };
192
246
  const sub = AppState.addEventListener("change", onChange);
193
247
  return () => {
248
+ // A pending hydration must not fire an app-open for a mount that is already gone.
249
+ cancelled = true;
194
250
  // RN >= 0.65 returns a subscription with remove(); guard for older shims.
195
251
  if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
196
252
  };
@@ -10,6 +10,11 @@
10
10
  * Each firing mints its OWN per-open `session_id` (held in a ref so a re-render never re-fires),
11
11
  * so grouping over time is by `deviceKey`/`userId`, never by session_id (see reportSessionStart).
12
12
  *
13
+ * NOT THE DEFAULT WIRING. `useLifecycleEvents` is: this hook emits `app.session_started` ONLY, so a
14
+ * host on this path has a permanently empty `app.first_open` and no top of funnel. Use it when you
15
+ * already own an open counter and deliberately want session starts alone; otherwise mount
16
+ * `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` once at the app root.
17
+ *
13
18
  * The hook is OPTIONAL. A host that already owns a session counter (most hosts do) can skip
14
19
  * the hook and call `reportSessionStart(...)` directly from its own "app opened" path — BOTH
15
20
  * paths are first-class. This hook is the batteries-included option for a host that has none.
@@ -21,7 +26,9 @@ import { useEffect, useRef } from "react";
21
26
  import { AppState, Platform, type AppStateStatus } from "react-native";
22
27
 
23
28
  import type { ClientEventTarget } from "../analytics/reportClientEvent";
29
+ import { resolveAutoDeviceKey } from "../context/deviceId";
24
30
  import { collectDeviceContext } from "../device/deviceContext";
31
+ import type { WireOnboardingStorage } from "../session/persistedSession";
25
32
  import { reportSessionStart } from "./reportSessionStart";
26
33
 
27
34
  /** A foreground after at least this long in the background counts as a NEW app-open (30 min). */
@@ -35,6 +42,11 @@ export interface SessionStartConfig {
35
42
  apiKey?: string;
36
43
  /** Host app version (e.g. "1.4.2"), forwarded for release segmentation. Optional. */
37
44
  appVersion?: string;
45
+ /** Tenant/app id — namespaces the auto `device_key` fallback below. */
46
+ appId?: string;
47
+ /** Host storage (AsyncStorage subset). Present → `app.session_started` falls back to the kit's
48
+ * persisted auto `device_key` when the host passes none. Absent → no fallback (see below). */
49
+ storage?: WireOnboardingStorage;
38
50
  }
39
51
 
40
52
  /** Per-open identity the host supplies. All optional: a pre-auth open is device-only. */
@@ -66,6 +78,30 @@ export const useSessionStart = (
66
78
  latest.current = { config, options };
67
79
 
68
80
  useEffect(() => {
81
+ /**
82
+ * The `device_key` this open rides under. A host-supplied id always wins.
83
+ *
84
+ * BACKPORTED FROM `useLifecycleEvents` (which had this and this hook did not): `min_sessions` is
85
+ * computed server-side by counting distinct `app.session_started` grouped by
86
+ * `user_context.device_key`, so a host that passed no `deviceKey` emitted the counted event with
87
+ * NO key while its facade / activation events carried an auto-minted one — two disjoint identity
88
+ * spaces, and a counter that could never increase. The two session-start paths must not diverge
89
+ * on the thing the firing rule reads.
90
+ *
91
+ * ONLY WITH `storage`, for the same reason as the sibling hook: without persistence the auto id
92
+ * is per-LAUNCH, and a per-launch key makes every open look like a new device, corrupting
93
+ * `min_sessions` in the other direction.
94
+ */
95
+ const resolveDeviceKey = (
96
+ cfg: SessionStartConfig | undefined,
97
+ opts: UseSessionStartOptions,
98
+ ): string | undefined => {
99
+ const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
100
+ if (host) return host;
101
+ if (!cfg?.storage) return undefined;
102
+ return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
103
+ };
104
+
69
105
  const fire = () => {
70
106
  const { config: cfg, options: opts } = latest.current;
71
107
  if (!cfg?.serverUrl) return;
@@ -78,7 +114,7 @@ export const useSessionStart = (
78
114
  target,
79
115
  // A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
80
116
  userId: opts.userId,
81
- deviceKey: opts.deviceKey,
117
+ deviceKey: resolveDeviceKey(cfg, opts),
82
118
  sessionCount: opts.sessionCount,
83
119
  appVersion: cfg.appVersion ?? device.appVersion,
84
120
  platform: Platform.OS,
package/src/types.ts CHANGED
@@ -148,16 +148,53 @@ export type WireOnboardingProps = {
148
148
  /** Lifecycle hook for host-side analytics (started / per-turn / error). */
149
149
  onEvent?: (event: OnboardingEvent) => void;
150
150
  /**
151
- * Host-injected, non-PII context the app already knows about the user signup method,
152
- * referral source, plan tier, a HASHED user id, etc. Same host-injection philosophy as
153
- * `storage`: the kit collects nothing here; the host passes what it wants. Forwarded to the
154
- * backend on the session metadata AND on client events so analytics can segment the funnel.
151
+ * **THIS IS WHERE THE JOIN KEY GOES.** `user_context.device_key` is the ONLY thing that joins an
152
+ * onboarding session to everything the app reports later (analytics, purchases, gate decisions).
153
+ * Build the value with the helper so the wire spelling is decided in one place:
154
+ *
155
+ * ```tsx
156
+ * <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
157
+ * // no device id of your own? read the kit's:
158
+ * <WireOnboarding userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))} ... />
159
+ * ```
160
+ *
161
+ * SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
162
+ * this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
163
+ * analytics surfaces mint and persist — so the default wiring joins. Anything you DO pass wins
164
+ * verbatim and is never touched. See `autoJoinKey` for the opt-out and the two cases where the kit
165
+ * still cannot fill the gap (no `storage`, or you opted out), which keep warning in dev.
166
+ *
167
+ * Never hand-write `userContext={{ deviceKey }}`: the server's device lookup reads `device_key`,
168
+ * so a misspelled bucket produces a silently empty funnel rather than an error. And never join on
169
+ * `session_id` — an onboarding session id is the A2A `contextId` while an app-event session id is
170
+ * the per-open id, so intersecting those two id spaces returns zero rows every time.
171
+ *
172
+ * SECOND JOB, segmentation: anything else non-PII the app already knows — signup method, referral
173
+ * source, plan tier, a HASHED user id. Same host-injection philosophy as `storage`: the kit
174
+ * collects nothing here; the host passes what it wants. Forwarded to the backend on the session
175
+ * metadata AND on client events so analytics can segment the funnel.
155
176
  *
156
177
  * MUST NOT contain PII such as raw emails, names, or phone numbers — pass a hash if you need
157
178
  * a user key. Values are limited to primitives (`string | number | boolean`); the server caps
158
179
  * key count / size and drops deep nesting. Old servers ignore it (backward compatible).
159
180
  */
160
181
  userContext?: Record<string, string | number | boolean>;
182
+ /**
183
+ * OPT OUT of the automatic join key. Default `true`.
184
+ *
185
+ * By default (0.12.2+), an onboarding session that was given no `userContext.device_key` gets the
186
+ * kit's own per-install key injected — the SAME id `createAnalytics` / `createWireActivation` mint
187
+ * and persist — so the `activated` funnel joins without the host wiring anything. Pass
188
+ * `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session; that restores the
189
+ * pre-0.12.2 behavior exactly (nothing injected) and the dev warning fires again.
190
+ *
191
+ * Two things this flag does NOT do. It never overrides a `device_key` you passed — a host-supplied
192
+ * key always wins, whatever this is set to. And it cannot conjure a key without `storage`: with no
193
+ * persistence the kit's id is minted fresh every launch, and a per-launch key corrupts
194
+ * `min_sessions` instead of merely leaving the join empty, so the kit declines to inject and warns
195
+ * in dev instead.
196
+ */
197
+ autoJoinKey?: boolean;
161
198
  /**
162
199
  * The host's own user id, so onboarding sessions can be reconciled to real users later
163
200
  * (console sessions ↔ your user table / GA4 users). First-class alongside `userContext`.