@wireai/activation 0.12.0 → 0.12.2

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 (53) hide show
  1. package/AGENTS.md +13 -10
  2. package/CHANGELOG.md +130 -0
  3. package/INTEGRATION_PROMPT.md +14 -3
  4. package/README.md +40 -2
  5. package/dist/analytics/index.d.mts +5 -4
  6. package/dist/analytics/index.d.ts +5 -4
  7. package/dist/analytics/index.js +39 -24
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +39 -24
  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-DsSDHqor.d.mts → currentSession-BlCeDP0f.d.mts} +59 -5
  18. package/dist/{currentSession-D6RiVtc8.d.ts → currentSession-BxEB37xt.d.ts} +59 -5
  19. package/dist/index.d.mts +10 -20
  20. package/dist/index.d.ts +10 -20
  21. package/dist/index.js +301 -187
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +300 -188
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.js +41 -19
  26. package/dist/questionnaire/index.js.map +1 -1
  27. package/dist/questionnaire/index.mjs +41 -19
  28. package/dist/questionnaire/index.mjs.map +1 -1
  29. package/dist/reviews/index.js +41 -19
  30. package/dist/reviews/index.js.map +1 -1
  31. package/dist/reviews/index.mjs +41 -19
  32. package/dist/reviews/index.mjs.map +1 -1
  33. package/dist/showcase/index.js +15 -6
  34. package/dist/showcase/index.js.map +1 -1
  35. package/dist/showcase/index.mjs +15 -6
  36. package/dist/showcase/index.mjs.map +1 -1
  37. package/llms.txt +2 -0
  38. package/package.json +1 -1
  39. package/src/WireOnboarding.tsx +140 -5
  40. package/src/activation/wireActivation.ts +8 -1
  41. package/src/analytics/analyticsFacade.ts +15 -11
  42. package/src/analytics/reportClientEvent.ts +11 -7
  43. package/src/coachmarks/runtime.ts +53 -17
  44. package/src/config/wireConfigFromEnv.ts +46 -2
  45. package/src/context/deviceId.ts +82 -18
  46. package/src/index.ts +9 -1
  47. package/src/questionnaire/runtime.ts +1 -0
  48. package/src/questionnaire/useQuestionnaireGate.ts +8 -3
  49. package/src/reviews/runtime.ts +68 -7
  50. package/src/reviews/useReviewGate.ts +8 -3
  51. package/src/session-analytics/useLifecycleEvents.ts +57 -27
  52. package/src/session-analytics/useSessionStart.ts +37 -1
  53. package/src/types.ts +41 -4
@@ -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,7 +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";
32
- import { resolveAutoDeviceKey } from "../context/deviceId";
35
+ import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
33
36
  import { collectDeviceContext } from "../device/deviceContext";
34
37
  import type { WireOnboardingStorage } from "../session/persistedSession";
35
38
  import { reportFirstOpen } from "./lifecycle";
@@ -173,33 +176,58 @@ export const useLifecycleEvents = (
173
176
  });
174
177
  };
175
178
 
176
- // 1) session_start FIRST (mount = an app-open), so the server has ingested `session_started`
177
- // for `mountOpenSessionId` before first_open references the same id.
178
- 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);
179
185
 
180
- // 2) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to
181
- // the SAME per-open id so it is never a phantom session.
182
- {
183
186
  const { config: cfg, options: opts } = latest.current;
184
- if (opts.enabled !== false) {
185
- const device = collectDeviceContext();
186
- // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
187
- if (cfg?.appVersion) device.appVersion = cfg.appVersion;
188
- reportFirstOpen({
189
- target: targetOf(cfg),
190
- sink: resolveSink(),
191
- sessionId: mountOpenSessionId,
192
- storage: cfg?.storage,
193
- appId: cfg?.appId,
194
- userId: opts.userId,
195
- deviceKey: resolveDeviceKey(cfg, opts),
196
- sessionCount: opts.sessionCount,
197
- appVersion: cfg?.appVersion ?? device.appVersion,
198
- platform: Platform.OS,
199
- device,
200
- meta: opts.meta,
201
- });
202
- }
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();
203
231
  }
204
232
 
205
233
  // Foreground after a real background = a new app-open.
@@ -217,6 +245,8 @@ export const useLifecycleEvents = (
217
245
  };
218
246
  const sub = AppState.addEventListener("change", onChange);
219
247
  return () => {
248
+ // A pending hydration must not fire an app-open for a mount that is already gone.
249
+ cancelled = true;
220
250
  // RN >= 0.65 returns a subscription with remove(); guard for older shims.
221
251
  if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
222
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`.