@wireai/activation 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +62 -8
- package/CHANGELOG.md +188 -3
- package/INTEGRATION_PROMPT.md +25 -2
- package/README.md +112 -1
- package/dist/analytics/index.d.mts +18 -6
- package/dist/analytics/index.d.ts +18 -6
- package/dist/analytics/index.js +151 -41
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +147 -42
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +16 -0
- package/dist/coachmarks/index.d.ts +16 -0
- package/dist/coachmarks/index.js +19 -13
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +19 -13
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-C0_odnIW.d.mts → currentSession-BlCeDP0f.d.mts} +145 -33
- package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-BxEB37xt.d.ts} +145 -33
- package/dist/index.d.mts +243 -53
- package/dist/index.d.ts +243 -53
- package/dist/index.js +531 -156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +517 -157
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +1 -1
- package/dist/questionnaire/index.d.ts +1 -1
- package/dist/questionnaire/index.js +79 -14
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +79 -14
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +2 -2
- package/dist/reviews/index.d.ts +2 -2
- package/dist/reviews/index.js +103 -16
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +103 -16
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +15 -6
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +15 -6
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
- package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
- package/llms.txt +3 -0
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +140 -5
- package/src/activation/useWireActivation.ts +12 -1
- package/src/activation/wireActivation.ts +44 -25
- package/src/analytics/analyticsFacade.ts +54 -29
- package/src/analytics/currentSession.ts +83 -0
- package/src/analytics/eventQueue.ts +9 -1
- package/src/analytics/index.ts +20 -1
- package/src/analytics/reportClientEvent.ts +42 -0
- package/src/analytics/screenTracking.ts +6 -1
- package/src/analytics/useAnalytics.ts +22 -1
- package/src/coachmarks/runtime.ts +53 -17
- package/src/config/wireConfigFromEnv.ts +46 -2
- package/src/context/deviceId.ts +173 -0
- package/src/context/userContext.ts +18 -0
- package/src/index.ts +54 -2
- package/src/questionnaire/runtime.ts +13 -2
- package/src/questionnaire/useQuestionnaireGate.ts +11 -4
- package/src/revenuecat/index.ts +55 -0
- package/src/revenuecat/purchaseEvents.ts +167 -0
- package/src/revenuecat/revenueCatBridge.ts +221 -0
- package/src/revenuecat/types.ts +95 -0
- package/src/reviews/runtime.ts +153 -1
- package/src/reviews/transport.ts +21 -2
- package/src/reviews/useReviewGate.ts +14 -4
- package/src/session-analytics/lifecycle.ts +9 -2
- package/src/session-analytics/reportSessionStart.ts +15 -4
- package/src/session-analytics/useLifecycleEvents.ts +83 -27
- package/src/session-analytics/useSessionStart.ts +37 -1
- package/src/types.ts +41 -4
package/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
|
|
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).
|
|
@@ -50,6 +48,10 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
|
|
|
50
48
|
- `themeFromBrand({ primary })`: derive a full theme from one brand color.
|
|
51
49
|
- `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
|
|
52
50
|
- `defaultIllustrations`: dependency-free fallback art; spread your own over it.
|
|
51
|
+
- `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
|
|
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.
|
|
53
55
|
- Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`.
|
|
54
56
|
|
|
55
57
|
## `<WireOnboarding>` props
|
|
@@ -88,9 +90,10 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
|
|
|
88
90
|
| `metadata` | `Record<string, unknown>` | no | Merged into every A2A request (e.g. install attribution). The kit reserves `sessionId` + `supportedComponents`; do not override them. |
|
|
89
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). |
|
|
90
92
|
|
|
91
|
-
## App events
|
|
93
|
+
## App events (root export)
|
|
92
94
|
|
|
93
|
-
|
|
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`.
|
|
94
97
|
|
|
95
98
|
- `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)`: canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
|
|
96
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.
|
|
@@ -105,8 +108,9 @@ The app-event / analytics API is exported from the ROOT (`@wireai/activation`),
|
|
|
105
108
|
4. **Config**: set `EXPO_PUBLIC_WIREAI_API_KEY` / `_SERVER_URL` / `_APP_ID`; build via `wireConfigFromEnv({ appId })`.
|
|
106
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.
|
|
107
110
|
6. **Theme**: `themeFromBrand({ primary })` or a full `Partial<OnboardingTheme>`; for dark/light, pick the theme by the app's theme state.
|
|
108
|
-
7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate.
|
|
109
|
-
8. **
|
|
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.
|
|
110
114
|
|
|
111
115
|
## Review firing (do not hand-roll the decision fetch)
|
|
112
116
|
|
|
@@ -131,6 +135,56 @@ write your own fetch.
|
|
|
131
135
|
`createWireActivation(config)`. This is the kit-owned replacement for hand-rolling
|
|
132
136
|
session-id + await-POST + revalidate.
|
|
133
137
|
|
|
138
|
+
## RevenueCat (the purchase funnel)
|
|
139
|
+
|
|
140
|
+
The app sells subscriptions through `react-native-purchases`? Wire the paywall to the same event
|
|
141
|
+
stream as onboarding. The kit does NOT depend on `react-native-purchases` (it is a native module and
|
|
142
|
+
the kit never forces a rebuild); the adapter types the RevenueCat objects structurally, so you pass
|
|
143
|
+
the real ones you already have.
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
import { createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
|
|
147
|
+
import { createAnalytics } from "@wireai/activation/analytics";
|
|
148
|
+
|
|
149
|
+
const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
|
|
150
|
+
const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
|
|
151
|
+
|
|
152
|
+
revenuecat.paywallShown(offering, { source: variant });
|
|
153
|
+
revenuecat.checkoutStarted(pkg, { source: variant });
|
|
154
|
+
const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
|
|
155
|
+
revenuecat.purchaseFailed(error, pkg, { source: variant });
|
|
156
|
+
revenuecat.purchasesRestored(customerInfo);
|
|
157
|
+
revenuecat.syncPlanTier(customerInfo); // at launch, writes plan_tier with no event
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Canonical names (they are also the `question_key` strings a firing trigger matches on):
|
|
161
|
+
`wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`, `wire_purchase_failed`,
|
|
162
|
+
`wire_purchase_restored`.
|
|
163
|
+
|
|
164
|
+
**The join key is `user_context.device_key`, never `session_id`.** An onboarding session id is the
|
|
165
|
+
A2A `contextId` and an app-event session id is the per-open id; the two live in separate spaces on
|
|
166
|
+
purpose, so intersecting them returns nothing. Put the SAME device key on both sides: the analytics
|
|
167
|
+
and activation surfaces auto-mint and stamp it on every event, and onboarding gets it from
|
|
168
|
+
`<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`. Never hand-write
|
|
169
|
+
`userContext={{ deviceKey }}`, because the server's device lookup reads `device_key` and a
|
|
170
|
+
misspelled bucket produces an empty funnel instead of an error.
|
|
171
|
+
|
|
172
|
+
**If the host owns no device id, read the kit's.** Do not leave the onboarding side blank.
|
|
173
|
+
`resolveAutoDeviceKey({ appId, storage })` returns the exact id `createAnalytics` /
|
|
174
|
+
`createWireActivation` auto-mint and persist for this install, so both halves of the join agree:
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
import { resolveAutoDeviceKey, activationJoinContext } from "@wireai/activation";
|
|
178
|
+
|
|
179
|
+
const deviceKey = resolveAutoDeviceKey({ appId, storage }); // the SAME id the analytics side stamps
|
|
180
|
+
<WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Always pass `storage`. Without it the id is per-LAUNCH, not per-install, and a per-launch key makes
|
|
184
|
+
every open look like a new device, which breaks `min_sessions` and A/B arm stickiness as surely as
|
|
185
|
+
no key breaks the join. The same rule governs the lifecycle wiring: `useLifecycleEvents` falls back
|
|
186
|
+
to this id for `app.session_started` / `app.first_open` ONLY when `config.storage` is present.
|
|
187
|
+
|
|
134
188
|
## Gotchas (do not miss)
|
|
135
189
|
|
|
136
190
|
- **EAS / cloud builds:** install via **git URL or registry**, never a local `file:` path *outside the app repo* (EAS won't resolve it).
|
package/CHANGELOG.md
CHANGED
|
@@ -3,10 +3,195 @@
|
|
|
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
|
-
## [Unreleased
|
|
6
|
+
## [0.12.2] — Unreleased
|
|
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
|
+
|
|
136
|
+
## [0.12.0] — 2026-07-27
|
|
137
|
+
|
|
138
|
+
The release Myelino 2.2.0 pins. Ships the RevenueCat path (#52), the identity/counting audit,
|
|
139
|
+
the session-id fallback contract, and the transport/config fixes behind a silently dead lifecycle
|
|
140
|
+
stream (a consumer's `first_open` read 6 all-time while the emitting code was deployed).
|
|
141
|
+
|
|
142
|
+
### Added
|
|
143
|
+
|
|
144
|
+
- **RevenueCat path (`createRevenueCatBridge`).** The purchase funnel is now a first-class, documented
|
|
145
|
+
path instead of glue every consumer writes itself. New ROOT exports: `createRevenueCatBridge`,
|
|
146
|
+
`WIRE_PURCHASE_EVENTS` (`wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`,
|
|
147
|
+
`wire_purchase_failed`, `wire_purchase_restored`), the pure mappers (`activeEntitlement`,
|
|
148
|
+
`resolvePlanTier`, `describePackage`, `describeEntitlement`, `isUserCancelled`, `describeFailure`),
|
|
149
|
+
`PLAN_TIER_CONTEXT_KEY`, and the structural RevenueCat types. The bridge reports through the
|
|
150
|
+
`createAnalytics` or `createWireActivation` instance the host already holds (a compile-time check in
|
|
151
|
+
`revenueCatBridge.ts` keeps both assignable), writes `plan_tier` (paid / trial / free) to the bound
|
|
152
|
+
user context, drops the store's localized error `message` and keeps only the stable RevenueCat
|
|
153
|
+
`code`, and reports a store-confirmed purchase that granted no entitlement as
|
|
154
|
+
`wire_purchase_failed` with `reason: "not_entitled"` rather than swallowing it. **No new dependency:
|
|
155
|
+
`react-native-purchases` is a native module and is never imported**, so the RevenueCat objects are
|
|
156
|
+
typed structurally and the host passes its real ones.
|
|
157
|
+
- **`activationJoinContext(deviceKey)`.** The `userContext` value that joins an onboarding session to
|
|
158
|
+
the app's later events. The join key is `user_context.device_key` and NEVER `session_id`: an
|
|
159
|
+
onboarding session id is the A2A `contextId`, an app-event session id is the per-open id, and
|
|
160
|
+
intersecting the two id spaces returns zero rows every time. This helper exists so the wire spelling
|
|
161
|
+
(`device_key`) is decided in one place; a hand-written `userContext={{ deviceKey }}` builds a bucket
|
|
162
|
+
the server's device lookup does not read, which produces a silently empty funnel rather than an
|
|
163
|
+
error. `src/revenuecat/joinKey.test.ts` locks both sides against each other.
|
|
164
|
+
- **One shared auto `device_key` per install.** `createAnalytics` and `createWireActivation` each
|
|
165
|
+
minted their OWN id and raced a storage read, so a host building both (the documented wiring)
|
|
166
|
+
stamped two different `device_key`s on one install — splitting the key that `min_sessions`, A/B arm
|
|
167
|
+
stickiness and the purchase-to-onboarding join all read. Now ONE id lives in a `globalThis` slot
|
|
168
|
+
keyed by `Symbol.for(...)` (the `currentSession` pattern). `useLifecycleEvents` now stamps the
|
|
169
|
+
shared key on lifecycle events too (storage-gated, so a per-launch id never corrupts the counter),
|
|
170
|
+
and `resolveAutoDeviceKey` is exported from the root and `./analytics` barrels.
|
|
171
|
+
|
|
172
|
+
### Fixed
|
|
7
173
|
|
|
8
|
-
|
|
9
|
-
|
|
174
|
+
- **Every event carries a `session_id`, or the server eats it.** `POST /v1/events` validates per
|
|
175
|
+
event inside a try/except that counts the failure as "skipped" and returns HTTP 200 anyway, so an
|
|
176
|
+
event without a session id was accepted by the wire and discarded. `ensureCurrentSessionId()`
|
|
177
|
+
returns the registered per-open id when there is one and otherwise mints one, REGISTERS it so later
|
|
178
|
+
events join the same session, and warns once per process in `__DEV__` (mount `useLifecycleEvents`
|
|
179
|
+
at the app root). Closes the `reportAppEvent(target, "screen", { deviceKey })` hole and
|
|
180
|
+
`wire.track()`'s refusal to send without a registered open.
|
|
181
|
+
- **`config.sessionId` on `createAnalytics` now warns in dev.** Passing it pins EVERY event from the
|
|
182
|
+
instance to that one frozen id and opts out of the live per-open session — the exact footgun that
|
|
183
|
+
flatlined a consumer's lifecycle metrics. The JSDoc and README now state the freeze semantics
|
|
184
|
+
honestly; omitting it is the correct default.
|
|
185
|
+
- **The persistent transport paths read the `/v1/events` ACK body.** A 200 was never a receipt: the
|
|
186
|
+
server reports discarded events only as `skipped` in the body. The event queue and
|
|
187
|
+
`reportSessionStart` now warn in dev with the skipped count. Log-only — retry/dequeue semantics
|
|
188
|
+
unchanged.
|
|
189
|
+
- **`user_context.app_version` falls back to `detectAppVersion()`** when the host passes nothing, so
|
|
190
|
+
version attribution on the facade path no longer depends on the host forwarding its own version.
|
|
191
|
+
|
|
192
|
+
## [0.11.0] — 2026-07-21
|
|
193
|
+
|
|
194
|
+
Architecture-audit fixes (#50/#51). One BEHAVIOR CHANGE, called out below.
|
|
10
195
|
|
|
11
196
|
### Changed (BEHAVIOR) — review gate no longer fires on the first session
|
|
12
197
|
|
package/INTEGRATION_PROMPT.md
CHANGED
|
@@ -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.
|
|
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,19 @@ 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
|
-
|
|
67
|
+
10. RevenueCat (only if my app sells subscriptions through `react-native-purchases`): wire my paywall
|
|
68
|
+
to the same event stream with `createRevenueCatBridge({ analytics, entitlementId: "<my
|
|
69
|
+
entitlement, e.g. pro>" })` from `@wireai/activation`. Do NOT add or import
|
|
70
|
+
`react-native-purchases` in the kit path; the adapter types the RevenueCat objects structurally,
|
|
71
|
+
so pass my real `PurchasesOffering` / `PurchasesPackage` / `CustomerInfo` straight in. Replace my
|
|
72
|
+
hand-rolled paywall analytics calls with `paywallShown` / `checkoutStarted` / `purchaseCompleted`
|
|
73
|
+
/ `purchaseFailed` / `purchasesRestored`, and call `syncPlanTier(customerInfo)` once at launch.
|
|
74
|
+
THE JOIN KEY: pass the SAME device key to the analytics instance AND to onboarding via
|
|
75
|
+
`<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, because purchases join to
|
|
76
|
+
onboarding on `user_context.device_key` and never on `session_id` (those are separate id spaces
|
|
77
|
+
and intersecting them returns zero rows). If my app has no single stable device key yet, say so
|
|
78
|
+
instead of inventing one.
|
|
79
|
+
11. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
|
|
57
80
|
|
|
58
81
|
Report back: the files you changed (path:line), the typecheck result, and anything you couldn't
|
|
59
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>` |
|
|
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)
|
|
@@ -370,6 +371,52 @@ await clearUserContext({ storage, appId }); // the wire/activation path (then re
|
|
|
370
371
|
|
|
371
372
|
**3. The auto `device_key` is a per-install identifier.** When you supply no `deviceKey`, the analytics and activation surfaces auto-mint a stable per-install `device_key`, persist it via `storage` (`wireai:analytics:deviceKey:<appId>`), and stamp it on every event so the server's review/questionnaire gating and A/B stickiness work out of the box. It carries no hardware id, no IDFA/GAID, and cannot be joined across apps (the privacy category of a first-party cookie), but it IS a persistent per-install id. If you adopt these surfaces, declare it in your App Privacy / Data Safety accordingly. Supply your own `deviceKey` to override it.
|
|
372
373
|
|
|
374
|
+
### Do not pass `sessionId` to `createAnalytics`
|
|
375
|
+
|
|
376
|
+
`CreateAnalyticsConfig.sessionId` is an opt-out knob, not a default. Set it and **every** event that
|
|
377
|
+
instance sends (including `identify`) is pinned to that one frozen id, and the instance stops
|
|
378
|
+
following the live per-open session the kit registers from `app.session_started` (see
|
|
379
|
+
[Session mapping](#session-mapping-know-when-a-user-opens-the-app-again)). Lifecycle analytics then
|
|
380
|
+
collapse onto a single device-scoped id: one "first open" for the life of the install, however many
|
|
381
|
+
times the user comes back. The kit warns about it in dev builds.
|
|
382
|
+
|
|
383
|
+
```tsx
|
|
384
|
+
const analytics = createAnalytics({ serverUrl, apiKey, storage, appId }); // ✅ follows each app-open
|
|
385
|
+
const analytics = createAnalytics({ serverUrl, apiKey, sessionId: myId }); // ⚠️ frozen for good
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Omit it. Pass one only if your host runs its own session lifecycle and owns the id the server should
|
|
389
|
+
correlate on. This is a different field from the per-open `sessionId` that `useWireActivation` and
|
|
390
|
+
`reportAppEvent` READ (that one is the live id, and reading it is always fine).
|
|
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
|
+
|
|
373
420
|
## Session mapping (know when a user opens the app again)
|
|
374
421
|
|
|
375
422
|
Onboarding runs once, on the first launch. Session mapping is the other half: every time the
|
|
@@ -965,6 +1012,70 @@ If you are outside React (a service, a saga), `createWireActivation(config)` is
|
|
|
965
1012
|
factory. It returns `{ track, sessionId, subscribeRevalidation, getRevalidationVersion }`: the
|
|
966
1013
|
same `track`, plus the raw pub/sub the hook wraps for you.
|
|
967
1014
|
|
|
1015
|
+
## RevenueCat (the purchase funnel)
|
|
1016
|
+
|
|
1017
|
+
If you sell subscriptions with [RevenueCat](https://www.revenuecat.com/), the paywall is the other end of the funnel onboarding starts. `createRevenueCatBridge` wires the two together: one constructor, then your existing paywall call sites.
|
|
1018
|
+
|
|
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.
|
|
1020
|
+
|
|
1021
|
+
```tsx
|
|
1022
|
+
import { createRevenueCatBridge } from "@wireai/activation";
|
|
1023
|
+
import { createAnalytics } from "@wireai/activation/analytics";
|
|
1024
|
+
|
|
1025
|
+
const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
|
|
1026
|
+
const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
Then five call sites, all fire-and-forget, none of which can throw at your paywall:
|
|
1030
|
+
|
|
1031
|
+
```tsx
|
|
1032
|
+
revenuecat.paywallShown(offering, { source: variant }); // offering_id + packages_count
|
|
1033
|
+
revenuecat.checkoutStarted(pkg, { source: variant }); // package, product, price, currency
|
|
1034
|
+
|
|
1035
|
+
const { customerInfo } = await Purchases.purchasePackage(pkg);
|
|
1036
|
+
if (revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant })) navigateOn();
|
|
1037
|
+
|
|
1038
|
+
revenuecat.purchaseFailed(error, pkg, { source: variant }); // reason: "cancelled" | "error"
|
|
1039
|
+
revenuecat.purchasesRestored(await Purchases.restorePurchases());
|
|
1040
|
+
revenuecat.syncPlanTier(await Purchases.getCustomerInfo()); // at launch, no event
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
**The names are canonical**, the same way `WIRE_ONBOARDING_EVENTS` standardizes the onboarding funnel: `wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`, `wire_purchase_failed`, `wire_purchase_restored`. They are `app_event` `question_key` values on the wire, so they are also the exact strings a review or questionnaire firing trigger matches on.
|
|
1044
|
+
|
|
1045
|
+
**What the bridge decides for you.** The entitlement check lives in one place instead of being copy-pasted at the purchase site and the restore site. `plan_tier` (`paid` / `trial` / `free`) is written to the bound user context on every entitlement change, so the whole funnel can be sliced paid versus free. The store's error `message` is dropped and only the stable RevenueCat `code` rides the event, because that message is localized, unbounded, and occasionally names the account it failed for. And a purchase the store confirmed that granted **no** entitlement fires `wire_purchase_failed` with `reason: "not_entitled"` rather than returning quietly, which is the usual shape of a broken product-to-entitlement mapping.
|
|
1046
|
+
|
|
1047
|
+
### The join key: `device_key`, never `session_id`
|
|
1048
|
+
|
|
1049
|
+
A purchase event is worth nothing unless you can join it to the same user's onboarding. Get this wrong and you do not get a wrong number, you get a permanent zero, which is far harder to notice.
|
|
1050
|
+
|
|
1051
|
+
`session_id` is not that key. The onboarding session id is the A2A `contextId`, the app-event session id gets minted per app-open, and the kit keeps those two id spaces apart on purpose. Intersect them and you get no rows. Ever.
|
|
1052
|
+
|
|
1053
|
+
The key is **`user_context.device_key`**, and you have to put it on both sides:
|
|
1054
|
+
|
|
1055
|
+
```tsx
|
|
1056
|
+
import { activationJoinContext } from "@wireai/activation";
|
|
1057
|
+
|
|
1058
|
+
// Purchase side: createAnalytics / createWireActivation auto-mint and persist a device_key
|
|
1059
|
+
// and stamp it on every event. Supply your own to override it.
|
|
1060
|
+
const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
|
|
1061
|
+
|
|
1062
|
+
// Onboarding side: forward the SAME key. The kit passes userContext verbatim into the A2A
|
|
1063
|
+
// session-start metadata, and the server records it on the session's session_started event.
|
|
1064
|
+
<WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} onComplete={persist} />
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
`activationJoinContext(deviceKey)` exists because the wire key is `device_key` and the prop-facing name is `deviceKey`. Hand-writing `userContext={{ deviceKey }}` produces a bucket the server's device lookup does not read, and you get the silent-zero funnel instead of an error. This is the one place that spelling is decided.
|
|
1068
|
+
|
|
1069
|
+
Skip the onboarding half and the purchase events are still valid on their own, they just cannot be attributed back to an onboarding.
|
|
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
|
+
|
|
968
1079
|
## Feature controls (per-module kill switches)
|
|
969
1080
|
|
|
970
1081
|
Every activation surface has a switch you can flip from the dashboard "in case of something":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-
|
|
2
|
-
import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-
|
|
3
|
-
export { A as AnalyticsEvent, C as ClearUserContextOptions,
|
|
1
|
+
export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.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';
|
|
@@ -33,7 +33,12 @@ interface ScreenTrackerOptions {
|
|
|
33
33
|
serverUrl: string;
|
|
34
34
|
apiKey: string;
|
|
35
35
|
};
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The onboarding/session id to correlate screen views with, when known. Omitting it no longer
|
|
38
|
+
* means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
|
|
39
|
+
* behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
|
|
40
|
+
* never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
|
|
41
|
+
*/
|
|
37
42
|
sessionId?: string;
|
|
38
43
|
/** A stable, non-PII device id — groups a device's sessions server-side. */
|
|
39
44
|
deviceKey?: string;
|
|
@@ -103,8 +108,15 @@ type CreateAnalyticsConfig = {
|
|
|
103
108
|
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
104
109
|
apiKey: string;
|
|
105
110
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
111
|
+
* ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
|
|
112
|
+
* this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
|
|
113
|
+
* following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
|
|
114
|
+
* analytics then collapse onto a single device-scoped session — one "first open", forever.
|
|
115
|
+
*
|
|
116
|
+
* OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
|
|
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.
|
|
108
120
|
*/
|
|
109
121
|
sessionId?: string;
|
|
110
122
|
/** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-
|
|
2
|
-
import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-
|
|
3
|
-
export { A as AnalyticsEvent, C as ClearUserContextOptions,
|
|
1
|
+
export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.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';
|
|
@@ -33,7 +33,12 @@ interface ScreenTrackerOptions {
|
|
|
33
33
|
serverUrl: string;
|
|
34
34
|
apiKey: string;
|
|
35
35
|
};
|
|
36
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The onboarding/session id to correlate screen views with, when known. Omitting it no longer
|
|
38
|
+
* means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
|
|
39
|
+
* behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
|
|
40
|
+
* never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
|
|
41
|
+
*/
|
|
37
42
|
sessionId?: string;
|
|
38
43
|
/** A stable, non-PII device id — groups a device's sessions server-side. */
|
|
39
44
|
deviceKey?: string;
|
|
@@ -103,8 +108,15 @@ type CreateAnalyticsConfig = {
|
|
|
103
108
|
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
104
109
|
apiKey: string;
|
|
105
110
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
111
|
+
* ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
|
|
112
|
+
* this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
|
|
113
|
+
* following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
|
|
114
|
+
* analytics then collapse onto a single device-scoped session — one "first open", forever.
|
|
115
|
+
*
|
|
116
|
+
* OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
|
|
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.
|
|
108
120
|
*/
|
|
109
121
|
sessionId?: string;
|
|
110
122
|
/** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
|