@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
|
@@ -128,6 +128,48 @@ export const buildEventsRequest = (
|
|
|
128
128
|
}
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
/** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
|
|
132
|
+
declare const __DEV__: boolean | undefined;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
|
|
136
|
+
*
|
|
137
|
+
* The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses (a missing
|
|
138
|
+
* `session_id`, a malformed payload) is counted in `skipped`, never surfaced in the status code. Every
|
|
139
|
+
* send path here reads `res.ok` alone, so a whole batch can evaporate behind a green response. This
|
|
140
|
+
* consumes the body of the PERSISTENT paths (the offline queue + session-start) and names the count.
|
|
141
|
+
*
|
|
142
|
+
* LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
|
|
143
|
+
* A response with no usable `.json` (an old server, a test mock) is silently ignored.
|
|
144
|
+
*
|
|
145
|
+
* DEV-GATED FIRST: the `__DEV__` check is the FIRST statement, before the body is even looked at.
|
|
146
|
+
* The check used to sit inside the `.then`, so a release build parsed the JSON of every
|
|
147
|
+
* persistent-path POST to build a warning no one would ever read. Nothing here runs in production.
|
|
148
|
+
*/
|
|
149
|
+
export const warnOnSkippedEvents = (res: unknown): void => {
|
|
150
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
151
|
+
if (typeof console === "undefined" || !console.warn) return;
|
|
152
|
+
try {
|
|
153
|
+
const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
|
|
154
|
+
if (typeof json !== "function") return;
|
|
155
|
+
void Promise.resolve(json.call(res))
|
|
156
|
+
.then((body) => {
|
|
157
|
+
const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
|
|
158
|
+
if (typeof skipped !== "number" || skipped <= 0) return;
|
|
159
|
+
console.warn(
|
|
160
|
+
`[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
|
|
161
|
+
"(skipped in the response body) — they are gone, not retried. The usual cause is an " +
|
|
162
|
+
"event with a missing or empty session_id.",
|
|
163
|
+
);
|
|
164
|
+
})
|
|
165
|
+
.catch(() => {
|
|
166
|
+
// Unreadable / already-consumed body — best-effort logging, swallow.
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
// A hostile response object — swallow.
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
131
173
|
/**
|
|
132
174
|
* POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
|
|
133
175
|
* a missing `fetch`, or a network failure is swallowed — the call returns immediately and the
|
|
@@ -42,7 +42,12 @@ export interface ScreenTrackerOptions {
|
|
|
42
42
|
* exposes. When omitted, the tracker still de-dups and fires `onScreen`, but sends nothing.
|
|
43
43
|
*/
|
|
44
44
|
target?: { serverUrl: string; apiKey: string };
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The onboarding/session id to correlate screen views with, when known. Omitting it no longer
|
|
47
|
+
* means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
|
|
48
|
+
* behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
|
|
49
|
+
* never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
|
|
50
|
+
*/
|
|
46
51
|
sessionId?: string;
|
|
47
52
|
/** A stable, non-PII device id — groups a device's sessions server-side. */
|
|
48
53
|
deviceKey?: string;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* analytics.track("content_share", { source: "feed" });
|
|
12
12
|
* // ...on reconnect: analytics.notifyOnline();
|
|
13
13
|
*/
|
|
14
|
-
import { useRef } from "react";
|
|
14
|
+
import { useEffect, useRef } from "react";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
createAnalytics,
|
|
@@ -38,5 +38,26 @@ export const useAnalytics = (
|
|
|
38
38
|
prevKeys.current = currentKeys;
|
|
39
39
|
ref.current = createAnalytics(config, options);
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// A LATE-ARRIVING host device key must still reach the instance.
|
|
43
|
+
//
|
|
44
|
+
// `createAnalytics` copies `config.userContext` into a closure at construction and never re-reads
|
|
45
|
+
// the prop, and `config.userContext.deviceKey` is deliberately NOT part of `currentKeys` (rebuilding
|
|
46
|
+
// the instance would throw away the event queue's pending buffer). So a host that hydrates its
|
|
47
|
+
// device id asynchronously — an AsyncStorage read that resolves after first render — used to be
|
|
48
|
+
// stamped with the kit's auto-minted `wdev_*` id FOREVER, while a sibling `useWireActivation`
|
|
49
|
+
// (which does key on it) rebuilt and used the real one. One install, two `device_key` values, in
|
|
50
|
+
// the same app, on the key every gating rule and the purchase↔onboarding join reads.
|
|
51
|
+
//
|
|
52
|
+
// `setUserContext` is the non-destructive seam for exactly this: it updates the bound context in
|
|
53
|
+
// place, so subsequent events carry the host key with no queue rebuild.
|
|
54
|
+
const hostDeviceKey =
|
|
55
|
+
typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
|
|
56
|
+
? config.userContext.deviceKey.trim()
|
|
57
|
+
: undefined;
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
|
|
60
|
+
}, [hostDeviceKey]);
|
|
61
|
+
|
|
41
62
|
return ref.current;
|
|
42
63
|
};
|
|
@@ -8,35 +8,71 @@
|
|
|
8
8
|
* the root by `CoachmarkProvider`. Singletons let a hook read the gate without
|
|
9
9
|
* every call site threading the storage through props. The provider is still the
|
|
10
10
|
* single writer — it calls `setCoachmarkStorage` / `setCoachmarkTesting` on mount.
|
|
11
|
+
*
|
|
12
|
+
* ── WHY A globalThis SLOT, NOT PLAIN MODULE VARIABLES ─────────────────────────────────────────
|
|
13
|
+
* These three values used to be plain module `let`s, and this module is inlined by tsup into FOUR
|
|
14
|
+
* dist bundles (`./coachmarks`, `./reviews`, `./questionnaire`, `./showcase`). Under `dist`
|
|
15
|
+
* resolution — node `import`/`require`, SSR, jest, RN-web, anything that is not Metro — a host that
|
|
16
|
+
* mounts `CoachmarkProvider` from `@wireai/activation/coachmarks` wrote the COACHMARKS copy, while
|
|
17
|
+
* `useReviewGate` from `@wireai/activation/reviews` read the REVIEWS copy, which was still `null`.
|
|
18
|
+
* Everything downstream then failed silently: `readInt(null, …)` returns 0, so `bumpSessionCount`
|
|
19
|
+
* returns 1 forever and the fail-closed `minSessions: 2` default is unsatisfiable (the gate NEVER
|
|
20
|
+
* fires); the `seen` once-gate never persists; and a tenant's coachmarks kill switch never reaches
|
|
21
|
+
* the reviews bundle. Metro masked it by collapsing every subpath back to one `src/` file through
|
|
22
|
+
* the `react-native` export condition, which is a bundler accident, not a guarantee.
|
|
23
|
+
*
|
|
24
|
+
* Same remedy this repo already applies to `currentSession`, the auto device-key registry and the
|
|
25
|
+
* activation revalidation counter: ONE record in a `globalThis` slot keyed by `Symbol.for(...)`, so
|
|
26
|
+
* every inlined copy of this module addresses the SAME state.
|
|
11
27
|
*/
|
|
12
28
|
import type { CoachmarkStorage } from "./types";
|
|
13
29
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
30
|
+
/** Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles. */
|
|
31
|
+
const COACHMARK_RUNTIME_SLOT: unique symbol = Symbol.for("@wireai/activation:coachmarkRuntime");
|
|
32
|
+
|
|
33
|
+
/** The shared runtime: the provider-injected storage, the QA replay flag, and the kill switch. */
|
|
34
|
+
type CoachmarkRuntime = {
|
|
35
|
+
storage: CoachmarkStorage | null;
|
|
36
|
+
testing: boolean;
|
|
37
|
+
// The coachmarks feature kill switch (from GET /v1/features). Default true = fail-open: with no
|
|
38
|
+
// flags fetched, coachmarks behave exactly as before. CoachmarkProvider writes it from the
|
|
39
|
+
// resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
|
|
40
|
+
// paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
|
|
41
|
+
coachmarksEnabled: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type GlobalWithCoachmarkRuntime = typeof globalThis & {
|
|
45
|
+
[COACHMARK_RUNTIME_SLOT]?: CoachmarkRuntime;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const runtimeGlobal = globalThis as GlobalWithCoachmarkRuntime;
|
|
49
|
+
|
|
50
|
+
const coachmarkRuntime = (): CoachmarkRuntime => {
|
|
51
|
+
const existing = runtimeGlobal[COACHMARK_RUNTIME_SLOT];
|
|
52
|
+
if (existing) return existing;
|
|
53
|
+
const created: CoachmarkRuntime = { storage: null, testing: false, coachmarksEnabled: true };
|
|
54
|
+
runtimeGlobal[COACHMARK_RUNTIME_SLOT] = created;
|
|
55
|
+
return created;
|
|
56
|
+
};
|
|
21
57
|
|
|
22
58
|
/**
|
|
23
59
|
* Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
|
|
24
60
|
* Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store.
|
|
25
61
|
*/
|
|
26
62
|
export const setCoachmarksEnabled = (value: boolean): void => {
|
|
27
|
-
coachmarksEnabled = value;
|
|
63
|
+
coachmarkRuntime().coachmarksEnabled = value;
|
|
28
64
|
};
|
|
29
65
|
|
|
30
66
|
/** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
|
|
31
|
-
export const areCoachmarksEnabled = (): boolean => coachmarksEnabled;
|
|
67
|
+
export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
|
|
32
68
|
|
|
33
69
|
/** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
|
|
34
70
|
export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
|
|
35
|
-
|
|
71
|
+
coachmarkRuntime().storage = storage;
|
|
36
72
|
};
|
|
37
73
|
|
|
38
74
|
/** The currently-injected gate storage, or null if no provider is mounted. */
|
|
39
|
-
export const getCoachmarkStorage = (): CoachmarkStorage | null =>
|
|
75
|
+
export const getCoachmarkStorage = (): CoachmarkStorage | null => coachmarkRuntime().storage;
|
|
40
76
|
|
|
41
77
|
/**
|
|
42
78
|
* Toggle the global QA replay flag. When true, EVERY seen-gate reads as unseen
|
|
@@ -44,11 +80,11 @@ export const getCoachmarkStorage = (): CoachmarkStorage | null => storageSinglet
|
|
|
44
80
|
* mount. The app flips ONE boolean to QA the whole coachmark surface.
|
|
45
81
|
*/
|
|
46
82
|
export const setCoachmarkTesting = (value: boolean): void => {
|
|
47
|
-
testing = value;
|
|
83
|
+
coachmarkRuntime().testing = value;
|
|
48
84
|
};
|
|
49
85
|
|
|
50
86
|
/** Whether replay-everything QA mode is on. */
|
|
51
|
-
export const isCoachmarkTesting = (): boolean => testing;
|
|
87
|
+
export const isCoachmarkTesting = (): boolean => coachmarkRuntime().testing;
|
|
52
88
|
|
|
53
89
|
/** Gate key for a tour: `wire_coachmark_<tourId>_seen`. */
|
|
54
90
|
export const coachmarkGateKey = (tourId: string): string =>
|
|
@@ -73,8 +109,8 @@ export const hasSeenGate = (
|
|
|
73
109
|
storageOverride?: CoachmarkStorage | null,
|
|
74
110
|
testingOverride?: boolean,
|
|
75
111
|
): boolean => {
|
|
76
|
-
if (testingOverride ??
|
|
77
|
-
const storage = storageOverride ??
|
|
112
|
+
if (testingOverride ?? isCoachmarkTesting()) return false;
|
|
113
|
+
const storage = storageOverride ?? getCoachmarkStorage();
|
|
78
114
|
if (!storage) return false;
|
|
79
115
|
try {
|
|
80
116
|
return storage.getItem(key) === SEEN_VALUE;
|
|
@@ -92,8 +128,8 @@ export const markSeenGate = (
|
|
|
92
128
|
storageOverride?: CoachmarkStorage | null,
|
|
93
129
|
testingOverride?: boolean,
|
|
94
130
|
): void => {
|
|
95
|
-
if (testingOverride ??
|
|
96
|
-
const storage = storageOverride ??
|
|
131
|
+
if (testingOverride ?? isCoachmarkTesting()) return;
|
|
132
|
+
const storage = storageOverride ?? getCoachmarkStorage();
|
|
97
133
|
if (!storage) return;
|
|
98
134
|
try {
|
|
99
135
|
storage.setItem(key, SEEN_VALUE);
|
|
@@ -12,12 +12,31 @@
|
|
|
12
12
|
* return <WireOnboarding config={config} ... />;
|
|
13
13
|
*
|
|
14
14
|
* `appId` defaults to `EXPO_PUBLIC_WIREAI_APP_ID` (or "default"); pass overrides to
|
|
15
|
-
* set `appId`/`metadata` or to substitute the key/URL programmatically.
|
|
15
|
+
* set `appId`/`metadata` or to substitute the key/URL programmatically. `appVersion`
|
|
16
|
+
* defaults to `detectAppVersion()` so every surface built from this config carries it.
|
|
16
17
|
*/
|
|
18
|
+
import { detectAppVersion } from "../device/appVersion";
|
|
17
19
|
import type { WireOnboardingConfig } from "../types";
|
|
18
20
|
|
|
19
21
|
export type WireConfigOverrides = Partial<WireOnboardingConfig>;
|
|
20
22
|
|
|
23
|
+
/** The env vars this helper reads. Exported so a host preflight can assert them itself. */
|
|
24
|
+
export const WIRE_ENV_VARS = [
|
|
25
|
+
"EXPO_PUBLIC_WIREAI_API_KEY",
|
|
26
|
+
"EXPO_PUBLIC_WIREAI_SERVER_URL",
|
|
27
|
+
"EXPO_PUBLIC_WIREAI_APP_ID",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
31
|
+
declare const __DEV__: boolean | undefined;
|
|
32
|
+
|
|
33
|
+
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
|
|
34
|
+
const warnInDev = (message: string): void => {
|
|
35
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
36
|
+
console.warn(message);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
21
40
|
/**
|
|
22
41
|
* Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
|
|
23
42
|
* In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
|
|
@@ -41,12 +60,37 @@ export const wireConfigFromEnv = (
|
|
|
41
60
|
overrides?.appId ?? process.env.EXPO_PUBLIC_WIREAI_APP_ID ?? "default";
|
|
42
61
|
|
|
43
62
|
// Missing transport → null, so the host gates on one falsy check.
|
|
44
|
-
|
|
63
|
+
//
|
|
64
|
+
// The `null` return is the CORRECT contract and hosts rely on it — but it is also the quietest
|
|
65
|
+
// failure the kit has: a missing EAS env var makes this return null, and then the facade never
|
|
66
|
+
// constructs, `useLifecycleEvents` no-ops on `!cfg?.serverUrl`, and every gate returns null. Wire
|
|
67
|
+
// is 100% off, with no error anywhere. Name it once in dev, and name WHICH var is missing.
|
|
68
|
+
if (!apiKey || !serverUrl) {
|
|
69
|
+
const missing = [
|
|
70
|
+
apiKey ? undefined : "EXPO_PUBLIC_WIREAI_API_KEY",
|
|
71
|
+
serverUrl ? undefined : "EXPO_PUBLIC_WIREAI_SERVER_URL",
|
|
72
|
+
].filter(Boolean);
|
|
73
|
+
warnInDev(
|
|
74
|
+
`[wireai] wireConfigFromEnv() returned null: ${missing.join(" and ")} ${
|
|
75
|
+
missing.length > 1 ? "are" : "is"
|
|
76
|
+
} missing. The kit is now FULLY DISABLED (no onboarding, no analytics, no gates) and nothing ` +
|
|
77
|
+
"else will report an error. Set the var(s) in your env / EAS secrets.",
|
|
78
|
+
);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// `appVersion` is part of `WireOnboardingConfig`, but this helper could not carry it — so a host
|
|
83
|
+
// that built its config here forwarded `config.appVersion === undefined` to every surface. The
|
|
84
|
+
// facade and the lifecycle path each auto-detect their own fallback; `createWireActivation` does
|
|
85
|
+
// not, so every `wire.track` event shipped with no `app_version` at all. Detect once here (an
|
|
86
|
+
// explicit override still wins) and the whole config carries it.
|
|
87
|
+
const appVersion = overrides?.appVersion ?? detectAppVersion();
|
|
45
88
|
|
|
46
89
|
return {
|
|
47
90
|
apiKey,
|
|
48
91
|
serverUrl,
|
|
49
92
|
appId,
|
|
93
|
+
...(appVersion ? { appVersion } : {}),
|
|
50
94
|
...(overrides?.metadata ? { metadata: overrides.metadata } : {}),
|
|
51
95
|
};
|
|
52
96
|
};
|
package/src/context/deviceId.ts
CHANGED
|
@@ -41,3 +41,176 @@ export const mintDeviceId = (): string => {
|
|
|
41
41
|
const time = Date.now().toString(36);
|
|
42
42
|
return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
|
|
43
43
|
};
|
|
44
|
+
|
|
45
|
+
// ── The ONE auto device key per install ──────────────────────────────────────────────────────
|
|
46
|
+
//
|
|
47
|
+
// WHY A REGISTRY AND NOT A `let` PER FACTORY: `createAnalytics` and `createWireActivation` each
|
|
48
|
+
// used to mint their OWN id synchronously and then race a storage read to overwrite it. A host that
|
|
49
|
+
// creates BOTH (the documented wiring: a façade for `track`/`screen`, an activation instance for the
|
|
50
|
+
// gate-firing `wire.track`) therefore had TWO auto ids for ONE install. Every event carried whichever
|
|
51
|
+
// id its own surface minted, so the `device_key` the server groups a device's sessions under — the
|
|
52
|
+
// key `min_sessions`, A/B arm stickiness, and the purchase↔onboarding join all read — SPLIT in two.
|
|
53
|
+
// On a first run both also wrote their own id to the same storage slot, so which one survived was a
|
|
54
|
+
// coin flip. Same failure class as joining two event families on disjoint id spaces: no error, just
|
|
55
|
+
// halved counts and a join that misses.
|
|
56
|
+
//
|
|
57
|
+
// The fix is the pattern this repo already uses for `currentSession` and `activation revalidation`:
|
|
58
|
+
// ONE value in a `globalThis` slot keyed by `Symbol.for(...)`, so every inlined copy of this module
|
|
59
|
+
// (tsup duplicates modules across the `.` / `./analytics` bundles) addresses the SAME registry.
|
|
60
|
+
// Keyed by `appId` so two tenants in one process never share an id.
|
|
61
|
+
//
|
|
62
|
+
// RESIDUAL WINDOW: the storage read is async, so an event emitted in the milliseconds before
|
|
63
|
+
// hydration completes carries the freshly minted id rather than the persisted one. The registry makes
|
|
64
|
+
// every surface agree on WHICH id that is; it does not make the read sync. A caller that can afford to
|
|
65
|
+
// wait (the lifecycle hook's mount effect — see `hydrateAutoDeviceKey`) should await instead: its two
|
|
66
|
+
// events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
|
|
67
|
+
// millisecond of noise, it is a counter that can never exceed 1.
|
|
68
|
+
|
|
69
|
+
/** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
|
|
70
|
+
const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
|
|
71
|
+
|
|
72
|
+
/** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
|
|
73
|
+
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it. */
|
|
74
|
+
type AutoDeviceKeyRegistry = {
|
|
75
|
+
keys: Map<string, string>;
|
|
76
|
+
hydrating: Set<string>;
|
|
77
|
+
pending?: Map<string, Promise<string>>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type GlobalWithDeviceKeys = typeof globalThis & {
|
|
81
|
+
[AUTO_DEVICE_KEY_SLOT]?: AutoDeviceKeyRegistry;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const deviceKeyGlobal = globalThis as GlobalWithDeviceKeys;
|
|
85
|
+
|
|
86
|
+
const autoDeviceKeyRegistry = (): AutoDeviceKeyRegistry => {
|
|
87
|
+
const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
|
|
88
|
+
if (existing) return existing;
|
|
89
|
+
const created: AutoDeviceKeyRegistry = { keys: new Map(), hydrating: new Set() };
|
|
90
|
+
deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
|
|
91
|
+
return created;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The persistence subset {@link resolveAutoDeviceKey} needs (a strict subset of `WireOnboardingStorage`). */
|
|
95
|
+
export type DeviceKeyStorage = {
|
|
96
|
+
getItem(key: string): Promise<string | null>;
|
|
97
|
+
setItem(key: string, value: string): Promise<void>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Options for {@link resolveAutoDeviceKey}. Omitting `storage` gives a PROCESS-scoped id, not a
|
|
101
|
+
* per-install one — see the caller notes: a caller with no persistence must decide whether a
|
|
102
|
+
* per-launch id is better or worse than no id for its metric. */
|
|
103
|
+
export interface ResolveAutoDeviceKeyOptions {
|
|
104
|
+
/** Tenant/app id — namespaces both the registry entry and the storage slot. */
|
|
105
|
+
appId?: string;
|
|
106
|
+
/** Host persistence. Present → the id survives launches. Absent → process-scoped only. */
|
|
107
|
+
storage?: DeviceKeyStorage;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the id it settles on.
|
|
112
|
+
* The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the SAME read
|
|
113
|
+
* instead of starting a second one. Never rejects: any storage failure resolves to the live id.
|
|
114
|
+
*/
|
|
115
|
+
const startHydration = (
|
|
116
|
+
registry: AutoDeviceKeyRegistry,
|
|
117
|
+
appId: string,
|
|
118
|
+
storage: DeviceKeyStorage,
|
|
119
|
+
minted: string,
|
|
120
|
+
): Promise<string> => {
|
|
121
|
+
if (!registry.pending) registry.pending = new Map();
|
|
122
|
+
const existing = registry.pending.get(appId);
|
|
123
|
+
if (existing) return existing;
|
|
124
|
+
|
|
125
|
+
const slot = deviceIdStorageKey(appId);
|
|
126
|
+
const settled = (): string => registry.keys.get(appId) ?? minted;
|
|
127
|
+
let run: Promise<string>;
|
|
128
|
+
try {
|
|
129
|
+
run = Promise.resolve(storage.getItem(slot))
|
|
130
|
+
.then((saved) => {
|
|
131
|
+
const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
|
|
132
|
+
if (persisted) {
|
|
133
|
+
registry.keys.set(appId, persisted);
|
|
134
|
+
return persisted;
|
|
135
|
+
}
|
|
136
|
+
// First run on this install: persist the id we just minted so the next launch adopts it.
|
|
137
|
+
return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
|
|
138
|
+
})
|
|
139
|
+
.catch(settled);
|
|
140
|
+
} catch {
|
|
141
|
+
// A storage adapter that throws synchronously — degrade to the in-memory id.
|
|
142
|
+
run = Promise.resolve(settled());
|
|
143
|
+
}
|
|
144
|
+
registry.pending.set(appId, run);
|
|
145
|
+
return run;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The ONE auto-minted `device_key` for an install, shared by every kit surface.
|
|
150
|
+
*
|
|
151
|
+
* SYNCHRONOUS by contract (a fire-and-forget event path cannot await): returns the current live id
|
|
152
|
+
* immediately, minting one on first call. When `storage` is supplied it also kicks off a SINGLE
|
|
153
|
+
* hydration per `appId` that adopts the persisted id (or persists the freshly minted one). Callers
|
|
154
|
+
* should call this per EVENT rather than caching the return value, so an event built after hydration
|
|
155
|
+
* carries the persisted id.
|
|
156
|
+
*
|
|
157
|
+
* A host-supplied `deviceKey` always wins — callers must short-circuit before reaching this.
|
|
158
|
+
* Never throws: a missing, hung, or rejecting storage adapter degrades to the in-memory id.
|
|
159
|
+
*/
|
|
160
|
+
export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): string => {
|
|
161
|
+
const registry = autoDeviceKeyRegistry();
|
|
162
|
+
const appId = opts.appId ?? "default";
|
|
163
|
+
|
|
164
|
+
let id = registry.keys.get(appId);
|
|
165
|
+
if (!id) {
|
|
166
|
+
id = mintDeviceId();
|
|
167
|
+
registry.keys.set(appId, id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const storage = opts.storage;
|
|
171
|
+
// Single-flight: the FIRST caller with storage owns hydration for this appId; later callers just
|
|
172
|
+
// read whatever the registry currently holds.
|
|
173
|
+
if (storage && !registry.hydrating.has(appId)) {
|
|
174
|
+
registry.hydrating.add(appId);
|
|
175
|
+
void startHydration(registry, appId, storage, id);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return registry.keys.get(appId) ?? id;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The AWAITABLE sibling of {@link resolveAutoDeviceKey}: resolve to the auto `device_key` AFTER the
|
|
183
|
+
* persisted id has been read back (or written, on a first run), so the caller stamps the id this
|
|
184
|
+
* install will keep rather than the one that was minted a millisecond ago.
|
|
185
|
+
*
|
|
186
|
+
* WHY IT EXISTS: `resolveAutoDeviceKey` is synchronous by contract, so a caller firing at mount got
|
|
187
|
+
* the freshly minted id and the persisted one landed milliseconds later. For most events that is
|
|
188
|
+
* noise. For `app.session_started` it is the whole metric: the server computes `min_sessions` by
|
|
189
|
+
* counting distinct opens grouped by `device_key`, so a per-launch key there makes the counter
|
|
190
|
+
* structurally incapable of exceeding 1, and it splits `first_open` off from every event that
|
|
191
|
+
* follows it. Only a caller that can afford one storage read should use this; the fire-and-forget
|
|
192
|
+
* event paths must stay on the sync function.
|
|
193
|
+
*
|
|
194
|
+
* Never throws or rejects: a missing, hung, or rejecting adapter resolves to the in-memory id, and
|
|
195
|
+
* with no `storage` it resolves immediately (there is nothing to hydrate from).
|
|
196
|
+
*/
|
|
197
|
+
export const hydrateAutoDeviceKey = async (
|
|
198
|
+
opts: ResolveAutoDeviceKeyOptions = {},
|
|
199
|
+
): Promise<string> => {
|
|
200
|
+
// Mint + register synchronously first, so a waiter and a concurrent sync caller share ONE id.
|
|
201
|
+
const id = resolveAutoDeviceKey(opts);
|
|
202
|
+
if (!opts.storage) return id;
|
|
203
|
+
const registry = autoDeviceKeyRegistry();
|
|
204
|
+
const appId = opts.appId ?? "default";
|
|
205
|
+
const pending = registry.pending?.get(appId);
|
|
206
|
+
if (pending) await pending;
|
|
207
|
+
return registry.keys.get(appId) ?? id;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
|
|
211
|
+
export const resetAutoDeviceKeys = (): void => {
|
|
212
|
+
const registry = autoDeviceKeyRegistry();
|
|
213
|
+
registry.keys.clear();
|
|
214
|
+
registry.hydrating.clear();
|
|
215
|
+
registry.pending?.clear();
|
|
216
|
+
};
|
|
@@ -207,6 +207,24 @@ export const clearUserContext = async (opts: ClearUserContextOptions = {}): Prom
|
|
|
207
207
|
}
|
|
208
208
|
};
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The `userContext` value to hand `<WireOnboarding userContext={...} />` so an onboarding session
|
|
212
|
+
* and the app's later events (purchases, actions, screens) share ONE join key.
|
|
213
|
+
*
|
|
214
|
+
* WHY it exists as a named function instead of an inline object literal: the wire key is
|
|
215
|
+
* `device_key`, the prop-facing name is `deviceKey`, and the analytics surfaces auto-mint the value
|
|
216
|
+
* for you. A host that hand-writes `userContext={{ deviceKey }}` produces a bucket the server's
|
|
217
|
+
* device lookup does not read, and the resulting funnel is silently EMPTY rather than wrong. This is
|
|
218
|
+
* the one place that spelling is decided.
|
|
219
|
+
*
|
|
220
|
+
* Pass the SAME `deviceKey` you gave `createAnalytics` / `createWireActivation`. `session_id` is not
|
|
221
|
+
* a join key across those two families: an onboarding session id is the A2A `contextId` and an
|
|
222
|
+
* app-event session id is the per-open id, so intersecting them returns nothing.
|
|
223
|
+
*/
|
|
224
|
+
export const activationJoinContext = (
|
|
225
|
+
deviceKey: string,
|
|
226
|
+
): Record<string, string | number | boolean> => resolveUserContext({ deviceKey }).userContext ?? {};
|
|
227
|
+
|
|
210
228
|
/** Options for {@link resolveUserContext}. */
|
|
211
229
|
export interface ResolveUserContextOptions {
|
|
212
230
|
/**
|
package/src/index.ts
CHANGED
|
@@ -109,7 +109,12 @@ export type {
|
|
|
109
109
|
} from "./features";
|
|
110
110
|
|
|
111
111
|
// ─── Config + env helpers ─────────────────────────────────────────────────────
|
|
112
|
-
export {
|
|
112
|
+
export {
|
|
113
|
+
wireConfigFromEnv,
|
|
114
|
+
// The three `EXPO_PUBLIC_WIREAI_*` names, so a host preflight can assert its own env instead of
|
|
115
|
+
// discovering a missing var as a silently disabled kit (`wireConfigFromEnv` returns null).
|
|
116
|
+
WIRE_ENV_VARS,
|
|
117
|
+
} from "./config/wireConfigFromEnv";
|
|
113
118
|
export type { WireConfigOverrides } from "./config/wireConfigFromEnv";
|
|
114
119
|
export { isOnboardingEnabled } from "./config/onboardingFlag";
|
|
115
120
|
export type { OnboardingFlagOptions } from "./config/onboardingFlag";
|
|
@@ -153,6 +158,7 @@ export {
|
|
|
153
158
|
clearUserContext,
|
|
154
159
|
clearPiiFromContext,
|
|
155
160
|
analyticsUserIdStorageKey,
|
|
161
|
+
activationJoinContext,
|
|
156
162
|
RESERVED_USER_CONTEXT_KEYS,
|
|
157
163
|
EXTRA_KEY_PREFIX,
|
|
158
164
|
} from "./context/userContext";
|
|
@@ -162,11 +168,30 @@ export type {
|
|
|
162
168
|
ResolveUserContextOptions,
|
|
163
169
|
ClearUserContextOptions,
|
|
164
170
|
} from "./context/userContext";
|
|
165
|
-
export {
|
|
171
|
+
export {
|
|
172
|
+
mintDeviceId,
|
|
173
|
+
deviceIdStorageKey,
|
|
174
|
+
AUTO_DEVICE_ID_PREFIX,
|
|
175
|
+
// `resolveAutoDeviceKey` is the id `createAnalytics` / `createWireActivation` auto-mint and persist.
|
|
176
|
+
// It MUST be public: the documented purchase↔onboarding join is
|
|
177
|
+
// `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, and a host that owns NO device
|
|
178
|
+
// id of its own had no way to obtain the key the analytics side was already stamping — so its
|
|
179
|
+
// purchase events carried `wdev_*` while its onboarding session carried no `device_key` at all, and
|
|
180
|
+
// the join returned the silent zero the README warns about.
|
|
181
|
+
resolveAutoDeviceKey,
|
|
182
|
+
// The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
|
|
183
|
+
// afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
|
|
184
|
+
hydrateAutoDeviceKey,
|
|
185
|
+
resetAutoDeviceKeys,
|
|
186
|
+
} from "./context/deviceId";
|
|
187
|
+
export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
|
|
166
188
|
|
|
167
189
|
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
168
190
|
export {
|
|
169
191
|
getCurrentSessionId,
|
|
192
|
+
// Returns the registered per-open id, minting + registering one when no app-open has been
|
|
193
|
+
// registered yet — so no wire path can emit the empty `session_id` the server drops behind a 200.
|
|
194
|
+
ensureCurrentSessionId,
|
|
170
195
|
setCurrentSessionId,
|
|
171
196
|
resetCurrentSessionId,
|
|
172
197
|
} from "./analytics/currentSession";
|
|
@@ -189,6 +214,33 @@ export {
|
|
|
189
214
|
resetActivationRevalidation,
|
|
190
215
|
} from "./activation";
|
|
191
216
|
|
|
217
|
+
// ─── RevenueCat (purchase funnel → the same events stream, joined on device_key) ──
|
|
218
|
+
export {
|
|
219
|
+
createRevenueCatBridge,
|
|
220
|
+
WIRE_PURCHASE_EVENTS,
|
|
221
|
+
PLAN_TIER_CONTEXT_KEY,
|
|
222
|
+
activeEntitlement,
|
|
223
|
+
describeEntitlement,
|
|
224
|
+
describeFailure,
|
|
225
|
+
describePackage,
|
|
226
|
+
isUserCancelled,
|
|
227
|
+
resolvePlanTier,
|
|
228
|
+
} from "./revenuecat";
|
|
229
|
+
export type {
|
|
230
|
+
RevenueCatBridge,
|
|
231
|
+
RevenueCatBridgeConfig,
|
|
232
|
+
PurchaseProps,
|
|
233
|
+
WirePurchaseEventName,
|
|
234
|
+
PlanTier,
|
|
235
|
+
RevenueCatCustomerInfoLike,
|
|
236
|
+
RevenueCatEntitlementLike,
|
|
237
|
+
RevenueCatErrorLike,
|
|
238
|
+
RevenueCatOfferingLike,
|
|
239
|
+
RevenueCatPackageLike,
|
|
240
|
+
RevenueCatProductLike,
|
|
241
|
+
RevenueCatSink,
|
|
242
|
+
} from "./revenuecat";
|
|
243
|
+
|
|
192
244
|
// ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
|
|
193
245
|
export {
|
|
194
246
|
reportSessionStart,
|
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
* host that mounted CoachmarkProvider gets questionnaire gating for free - no second storage
|
|
6
6
|
* to wire. Once-per-user is keyed on the questionnaire `id`.
|
|
7
7
|
*/
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
resolveStorage,
|
|
10
|
+
readInt,
|
|
11
|
+
writeInt,
|
|
12
|
+
bumpSessionCount,
|
|
13
|
+
currentOpenId,
|
|
14
|
+
warnMissingGateStorage,
|
|
15
|
+
} from "../reviews/runtime";
|
|
9
16
|
|
|
10
17
|
/** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
|
|
11
18
|
export const questionnaireSeenKey = (id: string, version?: string): string =>
|
|
@@ -15,6 +22,10 @@ export const questionnaireSeenKey = (id: string, version?: string): string =>
|
|
|
15
22
|
export const questionnaireLastShownKey = (id: string): string =>
|
|
16
23
|
`wire_questionnaire_${id}_last`;
|
|
17
24
|
|
|
18
|
-
/** Session-count key, incremented once per
|
|
25
|
+
/** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
|
|
19
26
|
export const questionnaireSessionsKey = (id: string): string =>
|
|
20
27
|
`wire_questionnaire_${id}_sessions`;
|
|
28
|
+
|
|
29
|
+
/** Companion key holding the open id the counter was LAST incremented for (see `bumpSessionCount`). */
|
|
30
|
+
export const questionnaireSessionOpenKey = (id: string): string =>
|
|
31
|
+
`wire_questionnaire_${id}_open`;
|
|
@@ -20,11 +20,14 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
|
20
20
|
import { sameDecision, shallowEqual } from "../reviews/equality";
|
|
21
21
|
import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
|
|
22
22
|
import {
|
|
23
|
+
bumpSessionCount,
|
|
23
24
|
questionnaireLastShownKey,
|
|
24
25
|
questionnaireSeenKey,
|
|
26
|
+
questionnaireSessionOpenKey,
|
|
25
27
|
questionnaireSessionsKey,
|
|
26
28
|
readInt,
|
|
27
29
|
resolveStorage,
|
|
30
|
+
warnMissingGateStorage,
|
|
28
31
|
writeInt,
|
|
29
32
|
} from "./runtime";
|
|
30
33
|
import type {
|
|
@@ -71,13 +74,17 @@ export const useQuestionnaireGate = ({
|
|
|
71
74
|
);
|
|
72
75
|
const lastKey = questionnaireLastShownKey(config.id);
|
|
73
76
|
const sessionsKey = questionnaireSessionsKey(config.id);
|
|
77
|
+
const sessionOpenKey = questionnaireSessionOpenKey(config.id);
|
|
74
78
|
|
|
75
|
-
// Read (and bump) the
|
|
79
|
+
// Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount — see the same note
|
|
80
|
+
// in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
|
|
81
|
+
// StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
|
|
76
82
|
const sessions = useState(() => {
|
|
77
83
|
const store = resolveStorage(storage);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
// Same as `useReviewGate`: no storage means the counter never leaves 1, so the fail-closed
|
|
85
|
+
// minSessions rule is unsatisfiable and the gate silently never fires. Dev-only, once per process.
|
|
86
|
+
warnMissingGateStorage(store, "questionnaire");
|
|
87
|
+
return bumpSessionCount(store, sessionsKey, sessionOpenKey);
|
|
81
88
|
})[0];
|
|
82
89
|
|
|
83
90
|
// Gate the local rules behind an optional client-side timeout, so a reachable server gets a
|