@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
package/AGENTS.md CHANGED
@@ -31,16 +31,14 @@ Use it whenever someone wants "AI / dynamic / personalized onboarding" in a Reac
31
31
  Everything an integration needs lives on the ROOT import (`@wireai/activation`). The other
32
32
  subpaths are optional secondary feature modules; import one only if you use it.
33
33
 
34
- - `@wireai/activation` (root): `WireOnboarding`, the helpers/types below, and the app-event/analytics reporters.
34
+ - `@wireai/activation` (root): `WireOnboarding`, the helpers/types below, and the device-only event reporters (`reportClientEvent`, `makeSessionId`, `WIRE_ONBOARDING_EVENTS`, see "App events" below).
35
+ - `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`. These are NOT root exports; importing them from the root does not compile.
35
36
  - `@wireai/activation/coachmarks`: in-app coachmark tour (`CoachmarkProvider`, `useCoachmarkTour`).
36
37
  - `@wireai/activation/showcase`: post-onboarding feature showcase (`FeatureShowcase`).
37
38
  - `@wireai/activation/reviews`: store-review gating (`ReviewGate`, `ReviewModal`, `useReviewGate`, `fetchReviewDecision`).
38
39
  - `@wireai/activation/questionnaire`: in-app questionnaire gating (`QuestionnaireGate`).
39
40
  - `@wireai/activation/metro`: the `withWireOnboarding` Metro helper (see step 3).
40
41
 
41
- There is no `analytics` subpath: the app-event / analytics API is exported from the ROOT
42
- (see "App events / analytics" below).
43
-
44
42
  ## Public API (import from `@wireai/activation`)
45
43
 
46
44
  - `WireOnboarding`: the component (props below).
@@ -51,7 +49,9 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
51
49
  - `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
52
50
  - `defaultIllustrations`: dependency-free fallback art; spread your own over it.
53
51
  - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
54
- - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Read the RevenueCat section before you use it.
52
+ - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Every `<WireOnboarding>` needs it (see step 7).
53
+ - `resolveAutoDeviceKey({ appId, storage })`: the kit's own persisted per-install `device_key`, for a host that owns none.
54
+ - `useLifecycleEvents(config, options)`: the app-root lifecycle hook (see step 8). `useSessionStart` / `reportSessionStart` are the counter-owning alternatives.
55
55
  - Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`.
56
56
 
57
57
  ## `<WireOnboarding>` props
@@ -90,9 +90,10 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
90
90
  | `metadata` | `Record<string, unknown>` | no | Merged into every A2A request (e.g. install attribution). The kit reserves `sessionId` + `supportedComponents`; do not override them. |
91
91
  | `appVersion` | `string` | no | App version string (e.g. `"1.4.2"`); forwarded to the backend + client events so analytics can segment the funnel by app version. Auto-detected best-effort from `expo-constants`/`expo-application` (dependency-free) when omitted; pass it to override (yours wins). |
92
92
 
93
- ## App events / analytics (root export)
93
+ ## App events (root export)
94
94
 
95
- The app-event / analytics API is exported from the ROOT (`@wireai/activation`), NOT a subpath.
95
+ These device-only event helpers are exported from the ROOT (`@wireai/activation`). The analytics
96
+ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wireai/activation/analytics`.
96
97
 
97
98
  - `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)`: canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
98
99
  - `reportClientEvent(target, event)` / `reportClientEvents(target, events)`: report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`. `target = { serverUrl, apiKey }` is derived from `WireOnboardingConfig`. Fire-and-forget: never throws into the UI.
@@ -107,8 +108,9 @@ The app-event / analytics API is exported from the ROOT (`@wireai/activation`),
107
108
  4. **Config**: set `EXPO_PUBLIC_WIREAI_API_KEY` / `_SERVER_URL` / `_APP_ID`; build via `wireConfigFromEnv({ appId })`.
108
109
  5. **Fonts**: host must load the font families named in `theme.fonts.regular/medium/bold` (e.g. `expo-font`), else text falls back to system font.
109
110
  6. **Theme**: `themeFromBrand({ primary })` or a full `Partial<OnboardingTheme>`; for dark/light, pick the theme by the app's theme state.
110
- 7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate.
111
- 8. **Gate**: wrap behind a flag (env for dev, remote config for prod) **AND** `config != null`; fall through to existing onboarding when off/unconfigured. Decide who sees it (e.g. new signups only) in host nav logic.
111
+ 7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate. Pass the join key: `userContext={activationJoinContext(deviceKey)}`, or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` when the app owns no device id. Skipping it leaves the `activated` funnel permanently empty and reports no error.
112
+ 8. **Lifecycle (do not skip)**: mount `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` ONCE at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id that `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the kit's persisted auto `device_key` on `app.session_started`, which is what the server's `min_sessions` rule counts. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`.
113
+ 9. **Gate**: wrap behind a flag (env for dev, remote config for prod) **AND** `config != null`; fall through to existing onboarding when off/unconfigured. Decide who sees it (e.g. new signups only) in host nav logic.
112
114
 
113
115
  ## Review firing (do not hand-roll the decision fetch)
114
116
 
@@ -141,7 +143,8 @@ the kit never forces a rebuild); the adapter types the RevenueCat objects struct
141
143
  the real ones you already have.
142
144
 
143
145
  ```tsx
144
- import { createAnalytics, createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
146
+ import { createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
147
+ import { createAnalytics } from "@wireai/activation/analytics";
145
148
 
146
149
  const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
147
150
  const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
package/CHANGELOG.md CHANGED
@@ -3,6 +3,136 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
+ ## [0.12.2] — 2026-07-27
7
+
8
+ The auto-join patch. 0.12.1 taught the kit to complain about a missing join key; this one teaches it
9
+ to supply the key. `user_context.device_key` is the only thing that stitches an onboarding session to
10
+ everything the app reports later, the prop that carries it is optional, and two of three production
11
+ integrations shipped without it and read `activated: 0`. A warning fixes that for the next host, not
12
+ for the current one, so the default now produces a joined funnel.
13
+
14
+ ### Changed
15
+
16
+ - **⚠️ BEHAVIOR CHANGE: `<WireOnboarding>` supplies its own join key when the host supplied none.**
17
+ With `storage` present and no `device_key` in `userContext`, the kit merges in its own per-install
18
+ key, and both the A2A session-start metadata and the onboarding client events carry it. That key is
19
+ the SAME id `createAnalytics` / `createWireActivation` mint and persist, resolved through
20
+ `hydrateAutoDeviceKey({ appId, storage })` rather than the synchronous
21
+ `resolveAutoDeviceKey({ appId, storage })`, so it is the persisted id, not a pre-hydration mint.
22
+ Three limits, all deliberate:
23
+ - **A host-supplied key always wins**, forwarded verbatim, nothing merged over it. An integration
24
+ that already passes `activationJoinContext(deviceKey)` sends a byte-identical payload.
25
+ - **No `storage`, no injection.** Without persistence the id is minted fresh every launch, and the
26
+ server counts `min_sessions` by distinct app-opens grouped on `device_key`: a per-launch key
27
+ corrupts that counter instead of merely leaving the join empty. The kit declines and warns in dev.
28
+ - **The key is resolved BEFORE the provider mounts**, behind the loader gate the persisted-session
29
+ read already holds. `userContext` feeds the A2A config, and swapping it after mount would rebuild
30
+ the adapter and drop the server-learned `contextId`. The read is timeout-capped, so a hung storage
31
+ adapter degrades to no key instead of a stuck loader.
32
+
33
+ Who this changes: a host that deliberately wanted an unlinked onboarding session. That host now
34
+ passes `autoJoinKey={false}`.
35
+ - **The 0.12.1 missing-join-key dev warning is reconciled with the above.** It no longer fires when
36
+ auto-injection covered the gap. It still fires when the gap is genuinely open, and it now names
37
+ which of the three reasons applies: no `storage`, `autoJoinKey={false}`, or a storage adapter that
38
+ did not answer in time.
39
+ - **A hand-written `userContext={{ deviceKey }}` still warns, even though the kit injected.** That
40
+ host demonstrably owns a device id, and their app-side events carry it, so the two families still
41
+ land in disjoint id spaces. Injection makes the onboarding side self-consistent; only the host can
42
+ make it join. Silencing this would have undone the 0.12.1 fix for the most common mis-wiring.
43
+
44
+ ### Added
45
+
46
+ - **`autoJoinKey` prop on `<WireOnboarding>`,** the documented opt-out. Default `true`. Setting it to
47
+ `false` restores the pre-0.12.2 behavior exactly: nothing is injected and the dev warning fires.
48
+ Documented in the `userContext` JSDoc, the README props table, and the README join-key section.
49
+
50
+ ## [0.12.1] — 2026-07-27
51
+
52
+ The integration-contract patch. 0.12.0's runtime worked; what did not work was everything the package
53
+ let a host leave out. Two of three real integrations went wrong in ways the kit accepted without
54
+ complaint, and the review that produced this release found a third: the ordered integration steps in
55
+ every shipped doc never once said "mount `useLifecycleEvents`", while the kit's own runtime warning
56
+ names that exact symbol as the fix. Nothing here tightens the wire; every new check is `__DEV__`-only
57
+ or a client-side default.
58
+
59
+ ### Added
60
+
61
+ - **`<WireOnboarding>` warns in dev when it got no join key.** `user_context.device_key` is the only
62
+ thing that joins an onboarding session to the app's later events, and the prop that carries it is
63
+ optional and named `userContext`, so forgetting it is the default and the failure is a silent zero
64
+ in the `activated` funnel. The warning names `activationJoinContext(deviceKey)` and, for a host that
65
+ owns no device id, `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`. The
66
+ `userContext` JSDoc + the README props row now lead with "this is where the join key goes" instead
67
+ of describing the prop as segmentation context. The prop is NOT renamed (breaking, for no gain), and
68
+ auto-injection is deliberately held for 0.13.0.
69
+ - **`wireConfigFromEnv` says "Wire is off" out loud in dev.** A missing EAS env var made it return
70
+ `null`, and then the facade never constructed, `useLifecycleEvents` no-oped, and every gate returned
71
+ null: three independent silent no-ops from one missing var. It now warns once, naming WHICH var is
72
+ missing and that the kit is fully disabled. The `null` return is unchanged, and hosts still gate on
73
+ it. `WIRE_ENV_VARS` is exported so a host preflight can assert the three names itself.
74
+ - **`hydrateAutoDeviceKey({ appId, storage })`,** the awaitable sibling of `resolveAutoDeviceKey`. It
75
+ joins the same single-flight storage read and resolves once the persisted id is in hand, for the one
76
+ caller that can afford to wait. Never rejects.
77
+ - **The review / questionnaire gates warn in dev when they have no sync storage.** With no storage the
78
+ app-open counter is pinned at 1 forever, so the fail-closed `minSessions: 2` default is
79
+ unsatisfiable and the gate never fires, which is indistinguishable from "the rules said not yet".
80
+ - **The lifecycle mount reached the docs.** `useLifecycleEvents` is now an ordered step in `AGENTS.md`
81
+ and `INTEGRATION_PROMPT.md`, a README section directly above "Session mapping", and a line in
82
+ `llms.txt`. `useSessionStart` / `reportSessionStart` are demoted to the "you already own a counter"
83
+ alternatives, with the fact that neither emits `app.first_open` stated where a reader will hit it.
84
+
85
+ ### Fixed
86
+
87
+ - **`useLifecycleEvents` stamped a freshly minted `device_key`, not the persisted one.**
88
+ `resolveAutoDeviceKey` is synchronous by contract, so a mount-time caller got a brand-new `wdev_*`
89
+ and adopted the persisted id milliseconds later. On every launch, both lifecycle events therefore
90
+ rode a key nothing else shared. The server computes `min_sessions` by counting distinct
91
+ `app.session_started` grouped by `device_key`, so that counter could never exceed 1, and
92
+ `app.first_open` (once ever) landed under a key no later event shares, which broke `first_open` to
93
+ `activated` cohorting too. The mount fire now awaits `hydrateAutoDeviceKey` — but ONLY when the auto
94
+ fallback is actually in play. A host-supplied `deviceKey`, or no `storage`, still fires
95
+ synchronously at mount.
96
+ - **The gate counter's unit could change mid-launch.** The gates call `bumpSessionCount` from a
97
+ `useState` initializer (render phase) while `useLifecycleEvents` registers the per-open id from a
98
+ root effect, and React runs every render before any effect. So a cold start counted a process id and
99
+ then a session id as TWO app-opens, which made `minSessions: 2` — the fail-closed default added
100
+ after the 2026-07-16 one-star incident — satisfiable inside a single launch. `currentOpenId()` now
101
+ pins whatever it first resolved for the whole launch. Behavioural, in the fail-closed direction: a
102
+ gate fires later, never earlier.
103
+ - **The coachmark storage singleton was a plain module `let`, duplicated across four dist bundles.**
104
+ Under `dist` resolution (node `import`/`require`, SSR, jest, RN-web, anything that is not Metro) a
105
+ host that mounted `CoachmarkProvider` from `@wireai/activation/coachmarks` wrote the coachmarks copy
106
+ while `useReviewGate` from `@wireai/activation/reviews` read the reviews copy, which was still
107
+ `null` — so the gate never fired, the `seen` once-gate never persisted, and a tenant's coachmarks
108
+ kill switch never reached the reviews bundle. The storage, the QA replay flag and the kill switch now
109
+ live in one `globalThis` slot keyed by `Symbol.for("@wireai/activation:coachmarkRuntime")`, the same
110
+ remedy already applied to `currentSession`, the auto device-key registry and activation revalidation.
111
+ - **The analytics facade read the session registry but never registered.** `resolveSessionId` was a
112
+ pure read with a per-instance fallback, so a `track` before the root lifecycle effect carried an id
113
+ the server had no `app.session_started` for and the server back-filled a phantom session, while a
114
+ `wire.track` moments later minted and registered a different one. It is now
115
+ `config.sessionId ?? ensureCurrentSessionId()`, a strict superset: when an open is registered this is
116
+ exactly `getCurrentSessionId()`, and the frozen-id opt-out is untouched.
117
+ - **`wireConfigFromEnv` structurally could not carry `appVersion`,** so a host that built its config
118
+ the documented way forwarded `undefined` everywhere. The facade and lifecycle paths each auto-detect
119
+ their own fallback; `createWireActivation` did not, so EVERY `wire.track` event shipped with no
120
+ `app_version` at all. The helper now defaults it to `detectAppVersion()` (an explicit override still
121
+ wins) and `createWireActivation` gained the same fallback as the facade.
122
+ - **`useSessionStart` had no auto `device_key` fallback,** while its sibling `useLifecycleEvents` did.
123
+ A host on the documented `useSessionStart` path emitted the very event `min_sessions` counts with no
124
+ device key while its other events carried an auto-minted one. Backported, storage-gated on the same
125
+ rule (no storage means no fallback, because a per-launch key corrupts the counter the other way).
126
+ - **`AGENTS.md` shipped an import that cannot resolve, and denied a subpath that exists.**
127
+ `import { createAnalytics } from "@wireai/activation"` does not compile (it lives on
128
+ `@wireai/activation/analytics`), and the same file claimed "There is no `analytics` subpath" while
129
+ `package.json` exports it, `tsup` builds it and `llms.txt` documents it. `README.md` shipped the same
130
+ broken import. Both are fixed, and `src/docsContract.test.ts` now resolves every documented
131
+ specifier against the BUILT `dist` through the real `exports` map, so the drift cannot return quietly.
132
+ - **`warnOnSkippedEvents` parsed the response body in release builds.** The `__DEV__` check sat inside
133
+ the `.then`, after `res.json()`, so every persistent-path POST paid a JSON parse to build a warning
134
+ nobody would read. The check is now the first statement in the function.
135
+
6
136
  ## [0.12.0] — 2026-07-27
7
137
 
8
138
  The release Myelino 2.2.0 pins. Ships the RevenueCat path (#52), the identity/counting audit,
@@ -40,10 +40,21 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
40
40
  - `fallbackFlow` MUST be my existing static onboarding so a backend error/timeout degrades, never
41
41
  dead-ends.
42
42
  - Forward install attribution if I have it: `wireConfigFromEnv({ appId, metadata: attributionMetadata({...}) })`.
43
+ - THE JOIN KEY: pass `userContext={activationJoinContext(deviceKey)}`. If my app owns no stable
44
+ device id, use `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`, which returns
45
+ the same id the analytics side stamps. Leaving it out makes the `activated` funnel read zero
46
+ forever and reports no error.
43
47
  - Analytics: log via `toAnalyticsEvent(e)` → my logger, using the canonical `wire_onboarding_*` names.
44
48
  7. Navigation: mount the screen in my first-run/signup flow; branch to the static flow when the gate
45
49
  is off or config is missing.
46
- 8. Event triggers (only if I asked to fire a review/questionnaire off an in-app action): pick the
50
+ 8. Lifecycle (do NOT skip): mount `useLifecycleEvents(config, { deviceKey, sessionCount, userId })`
51
+ ONCE at my app root, above the navigator, before anything else touches analytics. Pass
52
+ `config.storage`. It is the only path that emits `app.first_open`, it registers the per-open
53
+ session id every other surface correlates to, and with storage it stamps the kit's persisted auto
54
+ `device_key` on `app.session_started`, which is what the server counts `min_sessions` from.
55
+ `useSessionStart` / `reportSessionStart` are only for a host that already owns an open counter;
56
+ neither emits `first_open`.
57
+ 9. Event triggers (only if I asked to fire a review/questionnaire off an in-app action): pick the
47
58
  action that should trigger it (e.g. finishing a task, journaling a win) and its key, e.g.
48
59
  `journal_done`. On my home/gate screen call
49
60
  `const { track, sessionId, revalidation } = useWireActivation({ serverUrl: WIREAI_SERVER_URL, apiKey: WIREAI_API_KEY, appId: APP_ID, deviceKey })`
@@ -53,7 +64,7 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
53
64
  `useQuestionnaireGate` the same way), and at the action site call `await track("journal_done")`.
54
65
  Do NOT hand-roll the session id or the POST; `wire.track` owns both and revalidates the gate on
55
66
  a successful 2xx.
56
- 9. RevenueCat (only if my app sells subscriptions through `react-native-purchases`): wire my paywall
67
+ 10. RevenueCat (only if my app sells subscriptions through `react-native-purchases`): wire my paywall
57
68
  to the same event stream with `createRevenueCatBridge({ analytics, entitlementId: "<my
58
69
  entitlement, e.g. pro>" })` from `@wireai/activation`. Do NOT add or import
59
70
  `react-native-purchases` in the kit path; the adapter types the RevenueCat objects structurally,
@@ -65,7 +76,7 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
65
76
  onboarding on `user_context.device_key` and never on `session_id` (those are separate id spaces
66
77
  and intersecting them returns zero rows). If my app has no single stable device key yet, say so
67
78
  instead of inventing one.
68
- 10. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
79
+ 11. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
69
80
 
70
81
  Report back: the files you changed (path:line), the typecheck result, and anything you couldn't
71
82
  infer about my conventions.
package/README.md CHANGED
@@ -246,7 +246,8 @@ import { WireOnboarding } from "@wireai/activation";
246
246
  | `storage` | `WireOnboardingStorage` | Host-injected storage (AsyncStorage-compatible `getItem/setItem/removeItem`) for **session-id persistence**: an app KILL mid-onboarding resumes the SAME backend session instead of minting a new one, so the funnel's `started` count stays honest (no phantom drops). Pass AsyncStorage as-is, or a 3-line MMKV wrapper. Omit for the previous per-mount behavior. Persists the kit's own correlation seed only — answers stay the host's job via `onComplete`. |
247
247
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
248
248
  | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
249
- | `userContext` | `Record<string, string \| number \| boolean>` | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
249
+ | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` if your app owns no device id). Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other non-PII context the app already knows (signup method, referral, plan, a HASHED user id), forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
250
+ | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present, and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
250
251
  | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
251
252
 
252
253
  ## Helpers (the reusable substrate)
@@ -388,6 +389,34 @@ Omit it. Pass one only if your host runs its own session lifecycle and owns the
388
389
  correlate on. This is a different field from the per-open `sessionId` that `useWireActivation` and
389
390
  `reportAppEvent` READ (that one is the live id, and reading it is always fine).
390
391
 
392
+ ## Lifecycle events (mount this once, before anything else touches analytics)
393
+
394
+ One hook at your app root wires the whole top of the funnel: `app.first_open` once ever, and
395
+ `app.session_started` on every real app-open, both offline-buffered.
396
+
397
+ ```tsx
398
+ import { useLifecycleEvents } from "@wireai/activation";
399
+
400
+ // At the app root, above your navigator:
401
+ useLifecycleEvents(
402
+ { serverUrl, apiKey, appId, storage }, // same creds as onboarding
403
+ { deviceKey, sessionCount, userId }, // all optional
404
+ );
405
+ ```
406
+
407
+ Mount it first. Three other things read the per-open session it registers: `createAnalytics`,
408
+ `wire.track`, and the review / questionnaire gates. Without it every one of them falls back to an id
409
+ the server has no `app.session_started` for, so the session is thinner than a real open, and the kit
410
+ warns about it once in dev.
411
+
412
+ Pass `storage`. With it the hook falls back to the kit's persisted auto `device_key` when you supply
413
+ none, and waits for that key to be read back before it fires, so the events the server counts
414
+ `min_sessions` from carry the id this install keeps. Without storage the id would be per-launch, which
415
+ would make every open look like a new device, so the fallback deliberately does not engage.
416
+
417
+ The two alternatives below are for a host that already owns an open counter. Both emit
418
+ `app.session_started` only, so `app.first_open` stays empty forever on those paths.
419
+
391
420
  ## Session mapping (know when a user opens the app again)
392
421
 
393
422
  Onboarding runs once, on the first launch. Session mapping is the other half: every time the
@@ -990,7 +1019,8 @@ If you sell subscriptions with [RevenueCat](https://www.revenuecat.com/), the pa
990
1019
  The kit does **not** depend on `react-native-purchases`, and it never will. That package is a native module, and the kit's whole promise is that it never puts a native rebuild in your way. So the adapter types the RevenueCat objects structurally instead, which means you hand it the real `CustomerInfo` / `PurchasesPackage` / `PurchasesOffering` you already have. Nothing new gets installed.
991
1020
 
992
1021
  ```tsx
993
- import { createAnalytics, createRevenueCatBridge } from "@wireai/activation";
1022
+ import { createRevenueCatBridge } from "@wireai/activation";
1023
+ import { createAnalytics } from "@wireai/activation/analytics";
994
1024
 
995
1025
  const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
996
1026
  const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
@@ -1038,6 +1068,14 @@ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userConte
1038
1068
 
1039
1069
  Skip the onboarding half and the purchase events are still valid on their own, they just cannot be attributed back to an onboarding.
1040
1070
 
1071
+ **Since 0.12.2 the kit fills the gap for you.** If `<WireOnboarding>` gets `storage` and a `userContext` with no `device_key`, it injects its own per-install key: the SAME id `createAnalytics` / `createWireActivation` mint and persist, read back from storage rather than minted fresh. So the default wiring joins, and the code block above is now about *overriding* the key rather than about remembering it.
1072
+
1073
+ Three rules govern it:
1074
+
1075
+ - **Your key always wins.** Any non-empty `device_key` you pass is forwarded verbatim and nothing is merged over it. Existing integrations see a byte-identical payload.
1076
+ - **It needs `storage`.** Without persistence the kit's id is minted fresh on every launch, and a per-launch key is worse than no key: the server counts `min_sessions` by distinct app-opens grouped on `device_key`, so it corrupts the counter instead of leaving the join empty. With no `storage` the kit declines to inject and warns in dev.
1077
+ - **You can opt out.** `autoJoinKey={false}` restores the pre-0.12.2 behavior for a host that genuinely wants an unlinked session. The dev warning fires again there, naming the opt-out as the reason nothing was injected.
1078
+
1041
1079
  ## Feature controls (per-module kill switches)
1042
1080
 
1043
1081
  Every activation surface has a switch you can flip from the dashboard "in case of something":
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DsSDHqor.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-DsSDHqor.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BlCeDP0f.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-BlCeDP0f.mjs';
4
4
  import '../types-CNUqMK0D.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-BcmagF6K.mjs';
@@ -114,8 +114,9 @@ type CreateAnalyticsConfig = {
114
114
  * analytics then collapse onto a single device-scoped session — one "first open", forever.
115
115
  *
116
116
  * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
117
- * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
118
- * your host runs its own session lifecycle and owns the id the server should correlate on.
117
+ * when no open has been registered yet it mints one AND registers it, so every later surface joins
118
+ * the same session instead of each inventing its own. Pass one ONLY if your host runs its own
119
+ * session lifecycle and owns the id the server should correlate on.
119
120
  */
120
121
  sessionId?: string;
121
122
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D6RiVtc8.js';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-D6RiVtc8.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BxEB37xt.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-BxEB37xt.js';
4
4
  import '../types-Buj9Lw9t.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-BcmagF6K.js';
@@ -114,8 +114,9 @@ type CreateAnalyticsConfig = {
114
114
  * analytics then collapse onto a single device-scoped session — one "first open", forever.
115
115
  *
116
116
  * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
117
- * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
118
- * your host runs its own session lifecycle and owns the id the server should correlate on.
117
+ * when no open has been registered yet it mints one AND registers it, so every later surface joins
118
+ * the same session instead of each inventing its own. Pass one ONLY if your host runs its own
119
+ * session lifecycle and owns the id the server should correlate on.
119
120
  */
120
121
  sessionId?: string;
121
122
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -414,17 +414,17 @@ var buildEventsRequest = (target, events) => {
414
414
  }
415
415
  };
416
416
  var warnOnSkippedEvents = (res) => {
417
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
418
+ if (typeof console === "undefined" || !console.warn) return;
417
419
  try {
418
420
  const json = res == null ? void 0 : res.json;
419
421
  if (typeof json !== "function") return;
420
422
  void Promise.resolve(json.call(res)).then((body) => {
421
423
  const skipped = body == null ? void 0 : body.skipped;
422
424
  if (typeof skipped !== "number" || skipped <= 0) return;
423
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
424
- console.warn(
425
- `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) (skipped in the response body) \u2014 they are gone, not retried. The usual cause is an event with a missing or empty session_id.`
426
- );
427
- }
425
+ console.warn(
426
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) (skipped in the response body) \u2014 they are gone, not retried. The usual cause is an event with a missing or empty session_id.`
427
+ );
428
428
  }).catch(() => {
429
429
  });
430
430
  } catch {
@@ -1119,6 +1119,31 @@ var autoDeviceKeyRegistry = () => {
1119
1119
  deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
1120
1120
  return created;
1121
1121
  };
1122
+ var startHydration = (registry, appId, storage, minted) => {
1123
+ if (!registry.pending) registry.pending = /* @__PURE__ */ new Map();
1124
+ const existing = registry.pending.get(appId);
1125
+ if (existing) return existing;
1126
+ const slot = deviceIdStorageKey(appId);
1127
+ const settled = () => {
1128
+ var _a2;
1129
+ return (_a2 = registry.keys.get(appId)) != null ? _a2 : minted;
1130
+ };
1131
+ let run;
1132
+ try {
1133
+ run = Promise.resolve(storage.getItem(slot)).then((saved) => {
1134
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
1135
+ if (persisted) {
1136
+ registry.keys.set(appId, persisted);
1137
+ return persisted;
1138
+ }
1139
+ return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
1140
+ }).catch(settled);
1141
+ } catch {
1142
+ run = Promise.resolve(settled());
1143
+ }
1144
+ registry.pending.set(appId, run);
1145
+ return run;
1146
+ };
1122
1147
  var resolveAutoDeviceKey = (opts = {}) => {
1123
1148
  var _a2, _b;
1124
1149
  const registry = autoDeviceKeyRegistry();
@@ -1131,25 +1156,16 @@ var resolveAutoDeviceKey = (opts = {}) => {
1131
1156
  const storage = opts.storage;
1132
1157
  if (storage && !registry.hydrating.has(appId)) {
1133
1158
  registry.hydrating.add(appId);
1134
- const slot = deviceIdStorageKey(appId);
1135
- const minted = id;
1136
- try {
1137
- void Promise.resolve(storage.getItem(slot)).then((saved) => {
1138
- const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
1139
- if (persisted) registry.keys.set(appId, persisted);
1140
- else void Promise.resolve(storage.setItem(slot, minted)).catch(() => {
1141
- });
1142
- }).catch(() => {
1143
- });
1144
- } catch {
1145
- }
1159
+ void startHydration(registry, appId, storage, id);
1146
1160
  }
1147
1161
  return (_b = registry.keys.get(appId)) != null ? _b : id;
1148
1162
  };
1149
1163
  var resetAutoDeviceKeys = () => {
1164
+ var _a2;
1150
1165
  const registry = autoDeviceKeyRegistry();
1151
1166
  registry.keys.clear();
1152
1167
  registry.hydrating.clear();
1168
+ (_a2 = registry.pending) == null ? void 0 : _a2.clear();
1153
1169
  };
1154
1170
 
1155
1171
  // src/analytics/analyticsFacade.ts
@@ -1159,11 +1175,10 @@ var warnInDev2 = (message) => {
1159
1175
  }
1160
1176
  };
1161
1177
  var createAnalytics = (config, options = {}) => {
1162
- var _a2, _b, _c, _d;
1163
- const instanceSessionId = (_a2 = config.sessionId) != null ? _a2 : makeSessionId();
1178
+ var _a2, _b, _c;
1164
1179
  const resolveSessionId = () => {
1165
- var _a3, _b2;
1166
- return (_b2 = (_a3 = config.sessionId) != null ? _a3 : getCurrentSessionId()) != null ? _b2 : instanceSessionId;
1180
+ var _a3;
1181
+ return (_a3 = config.sessionId) != null ? _a3 : ensureCurrentSessionId();
1167
1182
  };
1168
1183
  if (config.sessionId) {
1169
1184
  warnInDev2(
@@ -1171,8 +1186,8 @@ var createAnalytics = (config, options = {}) => {
1171
1186
  );
1172
1187
  }
1173
1188
  const detectedAppVersion = detectAppVersion();
1174
- let userContext = { ...(_b = config.userContext) != null ? _b : {} };
1175
- const hostDeviceKeyAtInit = typeof ((_c = config.userContext) == null ? void 0 : _c.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1189
+ let userContext = { ...(_a2 = config.userContext) != null ? _a2 : {} };
1190
+ const hostDeviceKeyAtInit = typeof ((_b = config.userContext) == null ? void 0 : _b.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1176
1191
  const autoDeviceKeyOptions = {
1177
1192
  appId: config.appId,
1178
1193
  // A host-supplied deviceKey opts out of minting AND persisting (unchanged contract).
@@ -1195,7 +1210,7 @@ var createAnalytics = (config, options = {}) => {
1195
1210
  envelope,
1196
1211
  ...options
1197
1212
  });
1198
- let boundUserId = sanitizeUserId((_d = config.userContext) == null ? void 0 : _d.userId);
1213
+ let boundUserId = sanitizeUserId((_c = config.userContext) == null ? void 0 : _c.userId);
1199
1214
  const storageKey = analyticsUserIdStorageKey(config.appId);
1200
1215
  if (config.storage) {
1201
1216
  void config.storage.getItem(storageKey).then((saved) => {