@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.
- package/AGENTS.md +13 -10
- package/CHANGELOG.md +130 -0
- package/INTEGRATION_PROMPT.md +14 -3
- package/README.md +40 -2
- package/dist/analytics/index.d.mts +5 -4
- package/dist/analytics/index.d.ts +5 -4
- package/dist/analytics/index.js +39 -24
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +39 -24
- 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-DsSDHqor.d.mts → currentSession-BlCeDP0f.d.mts} +59 -5
- package/dist/{currentSession-D6RiVtc8.d.ts → currentSession-BxEB37xt.d.ts} +59 -5
- package/dist/index.d.mts +10 -20
- package/dist/index.d.ts +10 -20
- package/dist/index.js +301 -187
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +300 -188
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +41 -19
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +41 -19
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +41 -19
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +41 -19
- 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/llms.txt +2 -0
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +140 -5
- package/src/activation/wireActivation.ts +8 -1
- package/src/analytics/analyticsFacade.ts +15 -11
- package/src/analytics/reportClientEvent.ts +11 -7
- package/src/coachmarks/runtime.ts +53 -17
- package/src/config/wireConfigFromEnv.ts +46 -2
- package/src/context/deviceId.ts +82 -18
- package/src/index.ts +9 -1
- package/src/questionnaire/runtime.ts +1 -0
- package/src/questionnaire/useQuestionnaireGate.ts +8 -3
- package/src/reviews/runtime.ts +68 -7
- package/src/reviews/useReviewGate.ts +8 -3
- package/src/session-analytics/useLifecycleEvents.ts +57 -27
- package/src/session-analytics/useSessionStart.ts +37 -1
- package/src/types.ts +41 -4
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Firing moments mirror {@link useSessionStart} exactly:
|
|
12
12
|
* • ON MOUNT — the app opened (cold start / provider first render). `app.first_open` fires here
|
|
13
|
-
* too (once ever, gated by the persisted flag in `reportFirstOpen`).
|
|
13
|
+
* too (once ever, gated by the persisted flag in `reportFirstOpen`). When the kit's AUTO device
|
|
14
|
+
* key is the one in play (no host `deviceKey`, `config.storage` present) the mount fire waits on
|
|
15
|
+
* one storage read so both events carry the PERSISTED key, not a freshly minted one — see the
|
|
16
|
+
* `hydrateAutoDeviceKey` note below.
|
|
14
17
|
* • ON FOREGROUND after a real background of at least {@link BACKGROUND_SESSION_MS} (30 min) — a
|
|
15
18
|
* new app-open, so a fresh `app.session_started` fires. A quick app-switch does NOT count.
|
|
16
19
|
*
|
|
@@ -29,7 +32,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
|
29
32
|
|
|
30
33
|
import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
|
|
31
34
|
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
32
|
-
import { resolveAutoDeviceKey } from "../context/deviceId";
|
|
35
|
+
import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
|
|
33
36
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
34
37
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
35
38
|
import { reportFirstOpen } from "./lifecycle";
|
|
@@ -173,33 +176,58 @@ export const useLifecycleEvents = (
|
|
|
173
176
|
});
|
|
174
177
|
};
|
|
175
178
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
+
// The mount open: session_start FIRST (mount = an app-open), so the server has ingested
|
|
180
|
+
// `session_started` for `mountOpenSessionId` before first_open references the same id; then
|
|
181
|
+
// first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to the
|
|
182
|
+
// SAME per-open id so it is never a phantom session.
|
|
183
|
+
const fireMountOpen = () => {
|
|
184
|
+
fireSession(mountOpenSessionId);
|
|
179
185
|
|
|
180
|
-
// 2) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to
|
|
181
|
-
// the SAME per-open id so it is never a phantom session.
|
|
182
|
-
{
|
|
183
186
|
const { config: cfg, options: opts } = latest.current;
|
|
184
|
-
if (opts.enabled
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
187
|
+
if (opts.enabled === false) return;
|
|
188
|
+
const device = collectDeviceContext();
|
|
189
|
+
// `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
|
|
190
|
+
if (cfg?.appVersion) device.appVersion = cfg.appVersion;
|
|
191
|
+
reportFirstOpen({
|
|
192
|
+
target: targetOf(cfg),
|
|
193
|
+
sink: resolveSink(),
|
|
194
|
+
sessionId: mountOpenSessionId,
|
|
195
|
+
storage: cfg?.storage,
|
|
196
|
+
appId: cfg?.appId,
|
|
197
|
+
userId: opts.userId,
|
|
198
|
+
deviceKey: resolveDeviceKey(cfg, opts),
|
|
199
|
+
sessionCount: opts.sessionCount,
|
|
200
|
+
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
201
|
+
platform: Platform.OS,
|
|
202
|
+
device,
|
|
203
|
+
meta: opts.meta,
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// WAIT FOR THE PERSISTED DEVICE KEY, but ONLY when the auto fallback is the one in play.
|
|
208
|
+
//
|
|
209
|
+
// `resolveAutoDeviceKey` is synchronous by contract: it returns a freshly minted id and adopts the
|
|
210
|
+
// PERSISTED one a storage read later. Firing at mount therefore stamped a brand-new `wdev_*` on
|
|
211
|
+
// BOTH lifecycle events on every launch, while every other surface adopted the persisted id
|
|
212
|
+
// milliseconds afterwards. Those two events are exactly the ones the server counts `min_sessions`
|
|
213
|
+
// from (distinct `app.session_started` grouped by `device_key`), so the counter could never exceed
|
|
214
|
+
// 1 — and `first_open`, fired once ever, ended up under a key no later event shares, which breaks
|
|
215
|
+
// `first_open` → `activated` cohorting too. One storage read at mount buys both back.
|
|
216
|
+
//
|
|
217
|
+
// A host-supplied `deviceKey` needs no read, and with no `storage` there is nothing to read (and
|
|
218
|
+
// `resolveDeviceKey` deliberately does not fall back), so both keep firing synchronously at mount.
|
|
219
|
+
let cancelled = false;
|
|
220
|
+
const { config: mountCfg, options: mountOpts } = latest.current;
|
|
221
|
+
const hostKey =
|
|
222
|
+
typeof mountOpts.deviceKey === "string" && mountOpts.deviceKey.trim()
|
|
223
|
+
? mountOpts.deviceKey
|
|
224
|
+
: undefined;
|
|
225
|
+
if (!hostKey && mountCfg?.storage) {
|
|
226
|
+
void hydrateAutoDeviceKey({ appId: mountCfg.appId, storage: mountCfg.storage }).then(() => {
|
|
227
|
+
if (!cancelled) fireMountOpen();
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
fireMountOpen();
|
|
203
231
|
}
|
|
204
232
|
|
|
205
233
|
// Foreground after a real background = a new app-open.
|
|
@@ -217,6 +245,8 @@ export const useLifecycleEvents = (
|
|
|
217
245
|
};
|
|
218
246
|
const sub = AppState.addEventListener("change", onChange);
|
|
219
247
|
return () => {
|
|
248
|
+
// A pending hydration must not fire an app-open for a mount that is already gone.
|
|
249
|
+
cancelled = true;
|
|
220
250
|
// RN >= 0.65 returns a subscription with remove(); guard for older shims.
|
|
221
251
|
if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
|
|
222
252
|
};
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* Each firing mints its OWN per-open `session_id` (held in a ref so a re-render never re-fires),
|
|
11
11
|
* so grouping over time is by `deviceKey`/`userId`, never by session_id (see reportSessionStart).
|
|
12
12
|
*
|
|
13
|
+
* NOT THE DEFAULT WIRING. `useLifecycleEvents` is: this hook emits `app.session_started` ONLY, so a
|
|
14
|
+
* host on this path has a permanently empty `app.first_open` and no top of funnel. Use it when you
|
|
15
|
+
* already own an open counter and deliberately want session starts alone; otherwise mount
|
|
16
|
+
* `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` once at the app root.
|
|
17
|
+
*
|
|
13
18
|
* The hook is OPTIONAL. A host that already owns a session counter (most hosts do) can skip
|
|
14
19
|
* the hook and call `reportSessionStart(...)` directly from its own "app opened" path — BOTH
|
|
15
20
|
* paths are first-class. This hook is the batteries-included option for a host that has none.
|
|
@@ -21,7 +26,9 @@ import { useEffect, useRef } from "react";
|
|
|
21
26
|
import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
22
27
|
|
|
23
28
|
import type { ClientEventTarget } from "../analytics/reportClientEvent";
|
|
29
|
+
import { resolveAutoDeviceKey } from "../context/deviceId";
|
|
24
30
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
31
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
25
32
|
import { reportSessionStart } from "./reportSessionStart";
|
|
26
33
|
|
|
27
34
|
/** A foreground after at least this long in the background counts as a NEW app-open (30 min). */
|
|
@@ -35,6 +42,11 @@ export interface SessionStartConfig {
|
|
|
35
42
|
apiKey?: string;
|
|
36
43
|
/** Host app version (e.g. "1.4.2"), forwarded for release segmentation. Optional. */
|
|
37
44
|
appVersion?: string;
|
|
45
|
+
/** Tenant/app id — namespaces the auto `device_key` fallback below. */
|
|
46
|
+
appId?: string;
|
|
47
|
+
/** Host storage (AsyncStorage subset). Present → `app.session_started` falls back to the kit's
|
|
48
|
+
* persisted auto `device_key` when the host passes none. Absent → no fallback (see below). */
|
|
49
|
+
storage?: WireOnboardingStorage;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
/** Per-open identity the host supplies. All optional: a pre-auth open is device-only. */
|
|
@@ -66,6 +78,30 @@ export const useSessionStart = (
|
|
|
66
78
|
latest.current = { config, options };
|
|
67
79
|
|
|
68
80
|
useEffect(() => {
|
|
81
|
+
/**
|
|
82
|
+
* The `device_key` this open rides under. A host-supplied id always wins.
|
|
83
|
+
*
|
|
84
|
+
* BACKPORTED FROM `useLifecycleEvents` (which had this and this hook did not): `min_sessions` is
|
|
85
|
+
* computed server-side by counting distinct `app.session_started` grouped by
|
|
86
|
+
* `user_context.device_key`, so a host that passed no `deviceKey` emitted the counted event with
|
|
87
|
+
* NO key while its facade / activation events carried an auto-minted one — two disjoint identity
|
|
88
|
+
* spaces, and a counter that could never increase. The two session-start paths must not diverge
|
|
89
|
+
* on the thing the firing rule reads.
|
|
90
|
+
*
|
|
91
|
+
* ONLY WITH `storage`, for the same reason as the sibling hook: without persistence the auto id
|
|
92
|
+
* is per-LAUNCH, and a per-launch key makes every open look like a new device, corrupting
|
|
93
|
+
* `min_sessions` in the other direction.
|
|
94
|
+
*/
|
|
95
|
+
const resolveDeviceKey = (
|
|
96
|
+
cfg: SessionStartConfig | undefined,
|
|
97
|
+
opts: UseSessionStartOptions,
|
|
98
|
+
): string | undefined => {
|
|
99
|
+
const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
|
|
100
|
+
if (host) return host;
|
|
101
|
+
if (!cfg?.storage) return undefined;
|
|
102
|
+
return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
|
|
103
|
+
};
|
|
104
|
+
|
|
69
105
|
const fire = () => {
|
|
70
106
|
const { config: cfg, options: opts } = latest.current;
|
|
71
107
|
if (!cfg?.serverUrl) return;
|
|
@@ -78,7 +114,7 @@ export const useSessionStart = (
|
|
|
78
114
|
target,
|
|
79
115
|
// A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
|
|
80
116
|
userId: opts.userId,
|
|
81
|
-
deviceKey: opts
|
|
117
|
+
deviceKey: resolveDeviceKey(cfg, opts),
|
|
82
118
|
sessionCount: opts.sessionCount,
|
|
83
119
|
appVersion: cfg.appVersion ?? device.appVersion,
|
|
84
120
|
platform: Platform.OS,
|
package/src/types.ts
CHANGED
|
@@ -148,16 +148,53 @@ export type WireOnboardingProps = {
|
|
|
148
148
|
/** Lifecycle hook for host-side analytics (started / per-turn / error). */
|
|
149
149
|
onEvent?: (event: OnboardingEvent) => void;
|
|
150
150
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
151
|
+
* **THIS IS WHERE THE JOIN KEY GOES.** `user_context.device_key` is the ONLY thing that joins an
|
|
152
|
+
* onboarding session to everything the app reports later (analytics, purchases, gate decisions).
|
|
153
|
+
* Build the value with the helper so the wire spelling is decided in one place:
|
|
154
|
+
*
|
|
155
|
+
* ```tsx
|
|
156
|
+
* <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
|
|
157
|
+
* // no device id of your own? read the kit's:
|
|
158
|
+
* <WireOnboarding userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))} ... />
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
|
|
162
|
+
* this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
|
|
163
|
+
* analytics surfaces mint and persist — so the default wiring joins. Anything you DO pass wins
|
|
164
|
+
* verbatim and is never touched. See `autoJoinKey` for the opt-out and the two cases where the kit
|
|
165
|
+
* still cannot fill the gap (no `storage`, or you opted out), which keep warning in dev.
|
|
166
|
+
*
|
|
167
|
+
* Never hand-write `userContext={{ deviceKey }}`: the server's device lookup reads `device_key`,
|
|
168
|
+
* so a misspelled bucket produces a silently empty funnel rather than an error. And never join on
|
|
169
|
+
* `session_id` — an onboarding session id is the A2A `contextId` while an app-event session id is
|
|
170
|
+
* the per-open id, so intersecting those two id spaces returns zero rows every time.
|
|
171
|
+
*
|
|
172
|
+
* SECOND JOB, segmentation: anything else non-PII the app already knows — signup method, referral
|
|
173
|
+
* source, plan tier, a HASHED user id. Same host-injection philosophy as `storage`: the kit
|
|
174
|
+
* collects nothing here; the host passes what it wants. Forwarded to the backend on the session
|
|
175
|
+
* metadata AND on client events so analytics can segment the funnel.
|
|
155
176
|
*
|
|
156
177
|
* MUST NOT contain PII such as raw emails, names, or phone numbers — pass a hash if you need
|
|
157
178
|
* a user key. Values are limited to primitives (`string | number | boolean`); the server caps
|
|
158
179
|
* key count / size and drops deep nesting. Old servers ignore it (backward compatible).
|
|
159
180
|
*/
|
|
160
181
|
userContext?: Record<string, string | number | boolean>;
|
|
182
|
+
/**
|
|
183
|
+
* OPT OUT of the automatic join key. Default `true`.
|
|
184
|
+
*
|
|
185
|
+
* By default (0.12.2+), an onboarding session that was given no `userContext.device_key` gets the
|
|
186
|
+
* kit's own per-install key injected — the SAME id `createAnalytics` / `createWireActivation` mint
|
|
187
|
+
* and persist — so the `activated` funnel joins without the host wiring anything. Pass
|
|
188
|
+
* `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session; that restores the
|
|
189
|
+
* pre-0.12.2 behavior exactly (nothing injected) and the dev warning fires again.
|
|
190
|
+
*
|
|
191
|
+
* Two things this flag does NOT do. It never overrides a `device_key` you passed — a host-supplied
|
|
192
|
+
* key always wins, whatever this is set to. And it cannot conjure a key without `storage`: with no
|
|
193
|
+
* persistence the kit's id is minted fresh every launch, and a per-launch key corrupts
|
|
194
|
+
* `min_sessions` instead of merely leaving the join empty, so the kit declines to inject and warns
|
|
195
|
+
* in dev instead.
|
|
196
|
+
*/
|
|
197
|
+
autoJoinKey?: boolean;
|
|
161
198
|
/**
|
|
162
199
|
* The host's own user id, so onboarding sessions can be reconciled to real users later
|
|
163
200
|
* (console sessions ↔ your user table / GA4 users). First-class alongside `userContext`.
|