@wireai/activation 0.11.0 → 0.12.0
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 +51 -0
- package/CHANGELOG.md +58 -3
- package/INTEGRATION_PROMPT.md +13 -1
- package/README.md +73 -0
- package/dist/analytics/index.d.mts +17 -6
- package/dist/analytics/index.d.ts +17 -6
- package/dist/analytics/index.js +130 -35
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +126 -36
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-D6RiVtc8.d.ts} +88 -30
- package/dist/{currentSession-C0_odnIW.d.mts → currentSession-DsSDHqor.d.mts} +88 -30
- package/dist/index.d.mts +236 -36
- package/dist/index.d.ts +236 -36
- package/dist/index.js +281 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +269 -21
- 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 +49 -6
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +49 -6
- 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 +73 -8
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +73 -8
- package/dist/reviews/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 +1 -0
- package/package.json +1 -1
- package/src/activation/useWireActivation.ts +12 -1
- package/src/activation/wireActivation.ts +36 -24
- package/src/analytics/analyticsFacade.ts +41 -20
- 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 +38 -0
- package/src/analytics/screenTracking.ts +6 -1
- package/src/analytics/useAnalytics.ts +22 -1
- package/src/context/deviceId.ts +109 -0
- package/src/context/userContext.ts +18 -0
- package/src/index.ts +45 -1
- package/src/questionnaire/runtime.ts +12 -2
- package/src/questionnaire/useQuestionnaireGate.ts +9 -7
- 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 +92 -1
- package/src/reviews/transport.ts +21 -2
- package/src/reviews/useReviewGate.ts +12 -7
- package/src/session-analytics/lifecycle.ts +9 -2
- package/src/session-analytics/reportSessionStart.ts +15 -4
- package/src/session-analytics/useLifecycleEvents.ts +28 -2
|
@@ -94,7 +94,11 @@ interface FetchReviewDecisionOptions {
|
|
|
94
94
|
declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
|
|
95
95
|
/** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
|
|
96
96
|
interface ReportAppEventOptions {
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The onboarding/session id to correlate with, when known. Optional: when omitted the event
|
|
99
|
+
* still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
|
|
100
|
+
* with no `session_id` is dropped server-side behind a 200. Pass one only to override.
|
|
101
|
+
*/
|
|
98
102
|
sessionId?: string;
|
|
99
103
|
/** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
|
|
100
104
|
deviceKey?: string;
|
|
@@ -107,6 +111,16 @@ interface ReportAppEventOptions {
|
|
|
107
111
|
* stable identifier and `meta` small + non-PII.
|
|
108
112
|
*
|
|
109
113
|
* reportAppEvent(target, "content_share", { sessionId, deviceKey });
|
|
114
|
+
*
|
|
115
|
+
* ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
|
|
116
|
+
* The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
|
|
117
|
+
* validates per event inside a try/except that counts the failure as `skipped` and STILL returns
|
|
118
|
+
* HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
|
|
119
|
+
* fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
|
|
120
|
+
* `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
|
|
121
|
+
* posted every screen view into that hole. So the id is no longer conditional — an explicit
|
|
122
|
+
* `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
|
|
123
|
+
* has been registered yet).
|
|
110
124
|
*/
|
|
111
125
|
declare const reportAppEvent: (target: ReviewTarget | undefined, name: string, options?: ReportAppEventOptions) => void;
|
|
112
126
|
|
|
@@ -94,7 +94,11 @@ interface FetchReviewDecisionOptions {
|
|
|
94
94
|
declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
|
|
95
95
|
/** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
|
|
96
96
|
interface ReportAppEventOptions {
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The onboarding/session id to correlate with, when known. Optional: when omitted the event
|
|
99
|
+
* still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
|
|
100
|
+
* with no `session_id` is dropped server-side behind a 200. Pass one only to override.
|
|
101
|
+
*/
|
|
98
102
|
sessionId?: string;
|
|
99
103
|
/** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
|
|
100
104
|
deviceKey?: string;
|
|
@@ -107,6 +111,16 @@ interface ReportAppEventOptions {
|
|
|
107
111
|
* stable identifier and `meta` small + non-PII.
|
|
108
112
|
*
|
|
109
113
|
* reportAppEvent(target, "content_share", { sessionId, deviceKey });
|
|
114
|
+
*
|
|
115
|
+
* ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
|
|
116
|
+
* The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
|
|
117
|
+
* validates per event inside a try/except that counts the failure as `skipped` and STILL returns
|
|
118
|
+
* HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
|
|
119
|
+
* fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
|
|
120
|
+
* `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
|
|
121
|
+
* posted every screen view into that hole. So the id is no longer conditional — an explicit
|
|
122
|
+
* `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
|
|
123
|
+
* has been registered yet).
|
|
110
124
|
*/
|
|
111
125
|
declare const reportAppEvent: (target: ReviewTarget | undefined, name: string, options?: ReportAppEventOptions) => void;
|
|
112
126
|
|
package/llms.txt
CHANGED
|
@@ -31,6 +31,7 @@ The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (eac
|
|
|
31
31
|
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section.
|
|
32
32
|
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
|
|
33
33
|
- `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
|
|
34
|
+
- `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`.
|
|
34
35
|
- `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
|
|
35
36
|
|
|
36
37
|
## Files
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
|
@@ -59,7 +59,18 @@ export const useWireActivation = (config: WireActivationConfig): UseWireActivati
|
|
|
59
59
|
const ref = useRef<WireActivation | undefined>(undefined);
|
|
60
60
|
const prevKeys = useRef<string>("");
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// The identity of the built instance. `userContext.deviceKey` MUST be in here: `createWireActivation`
|
|
63
|
+
// honors it as an explicit device key (`config.deviceKey ?? config.userContext?.deviceKey`), so a host
|
|
64
|
+
// that hydrates its device id asynchronously and passes it only inside `userContext` would otherwise
|
|
65
|
+
// keep an instance frozen on the first render's `undefined` — the stale-static-ref failure this repo
|
|
66
|
+
// has already been bitten by (see .memory/70-knowledge.md, "Stale Option Closures in Static Refs").
|
|
67
|
+
const currentKeys = [
|
|
68
|
+
config.serverUrl,
|
|
69
|
+
config.apiKey,
|
|
70
|
+
config.appId,
|
|
71
|
+
config.deviceKey,
|
|
72
|
+
config.userContext?.deviceKey,
|
|
73
|
+
].join("|");
|
|
63
74
|
if (!ref.current || prevKeys.current !== currentKeys) {
|
|
64
75
|
prevKeys.current = currentKeys;
|
|
65
76
|
ref.current = createWireActivation(config);
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* await wire.track("journal_done"); // awaitable POST + auto-revalidate
|
|
13
13
|
*
|
|
14
14
|
* `track` POSTs `event_type='app_event'`, `question_key=<name>` (the EXACT string a review /
|
|
15
|
-
* questionnaire firing TRIGGER matches on) under the CURRENT `
|
|
15
|
+
* questionnaire firing TRIGGER matches on) under the CURRENT `ensureCurrentSessionId()` — the same id
|
|
16
16
|
* the gates pass to their `/decision` fetch, so the server's session-scoped trigger rule agrees —
|
|
17
17
|
* with `user_context.device_key` for the min-sessions / arm-assignment lookups. On a successful POST
|
|
18
18
|
* it bumps decision revalidation so a subscribed gate re-fetches and can fire.
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
* and `getCurrentSessionId` — it introduces NO second session concept and duplicates no POST path.
|
|
23
23
|
* React-free (the optional React glue is the thin `useWireActivation` hook).
|
|
24
24
|
*/
|
|
25
|
-
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
25
|
+
import { ensureCurrentSessionId, getCurrentSessionId } from "../analytics/currentSession";
|
|
26
26
|
import {
|
|
27
27
|
reportClientEventAwait,
|
|
28
28
|
type ClientEvent,
|
|
29
29
|
type ClientEventTarget,
|
|
30
30
|
} from "../analytics/reportClientEvent";
|
|
31
|
-
import {
|
|
31
|
+
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
32
32
|
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
33
33
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
34
|
import {
|
|
@@ -78,8 +78,14 @@ export type WireActivation = {
|
|
|
78
78
|
/**
|
|
79
79
|
* Awaitable action report: POST `event_type='app_event'`, `question_key=<name>`, optional `meta`,
|
|
80
80
|
* under the CURRENT session id + `user_context.device_key`. Resolves `true` once the server has
|
|
81
|
-
* stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump)
|
|
82
|
-
*
|
|
81
|
+
* stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump) for a blank
|
|
82
|
+
* name or a failed POST. Never throws.
|
|
83
|
+
*
|
|
84
|
+
* It no longer refuses when no app-open has been registered: the session id is resolved through
|
|
85
|
+
* `ensureCurrentSessionId()`, which mints and registers one in that case (the server requires a
|
|
86
|
+
* non-empty `session_id` and silently drops an event without one, so bailing lost the action
|
|
87
|
+
* entirely). A host that fires `reportSessionStart` / `useLifecycleEvents` first is unaffected —
|
|
88
|
+
* the real per-open id is already registered and gets used exactly as before.
|
|
83
89
|
*/
|
|
84
90
|
track(name: string, meta?: Record<string, unknown>): Promise<boolean>;
|
|
85
91
|
/** The CURRENT per-open session id (the kit's canonical `getCurrentSessionId()`), or `undefined`. */
|
|
@@ -98,27 +104,28 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
98
104
|
const target: ClientEventTarget = { serverUrl: config.serverUrl, apiKey: config.apiKey };
|
|
99
105
|
|
|
100
106
|
// Device key: an explicit id (top-level or in userContext) wins and is never overwritten; otherwise
|
|
101
|
-
//
|
|
107
|
+
// read the ONE process-wide auto id (`resolveAutoDeviceKey`) so this instance and a sibling
|
|
108
|
+
// `createAnalytics` instance carry the SAME `device_key` for the same install. Minting locally here
|
|
109
|
+
// gave one install two auto ids — see the registry note in context/deviceId.ts.
|
|
102
110
|
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (persisted) autoDeviceKey = persisted;
|
|
112
|
-
else void storage.setItem(key, autoDeviceKey).catch(() => {});
|
|
113
|
-
})
|
|
114
|
-
.catch(() => {});
|
|
115
|
-
}
|
|
111
|
+
const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
|
|
112
|
+
appId: config.appId,
|
|
113
|
+
// An explicit key opts out of minting AND persisting (unchanged contract).
|
|
114
|
+
storage: explicitDeviceKey ? undefined : config.storage,
|
|
115
|
+
};
|
|
116
|
+
// Start hydration AT CONSTRUCTION (not at the first `track`) so the persisted id is adopted as early
|
|
117
|
+
// as it used to be. The return value is deliberately discarded — every event re-resolves.
|
|
118
|
+
if (!explicitDeviceKey) resolveAutoDeviceKey(autoDeviceKeyOptions);
|
|
116
119
|
|
|
117
120
|
// Stamp the resolved rich context onto the event: `user_context` bucket (device_key always, plus any
|
|
118
121
|
// app_version / opt-in user_email / namespaced extra) and the top-level opaque `user_id`.
|
|
119
122
|
const applyContext = (event: ClientEvent): void => {
|
|
120
123
|
const resolved = resolveUserContext(
|
|
121
|
-
|
|
124
|
+
// `??` is lazy on purpose: an explicit key must never even touch the auto registry.
|
|
125
|
+
{
|
|
126
|
+
...(config.userContext ?? {}),
|
|
127
|
+
deviceKey: explicitDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions),
|
|
128
|
+
},
|
|
122
129
|
{ autoAppVersion: config.appVersion },
|
|
123
130
|
);
|
|
124
131
|
if (resolved.userContext) {
|
|
@@ -128,10 +135,15 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
128
135
|
};
|
|
129
136
|
|
|
130
137
|
const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
if (!clean(name)
|
|
138
|
+
// A blank name is the only thing left to refuse on: there is no `question_key` to match a
|
|
139
|
+
// firing trigger against, so bail WITHOUT bumping (a bump with no posted event would only make
|
|
140
|
+
// the gate re-fetch for nothing).
|
|
141
|
+
if (!clean(name)) return false;
|
|
142
|
+
// No app-open registered is NOT a reason to drop the action. `ensureCurrentSessionId` returns
|
|
143
|
+
// the registered per-open id when there is one (the unchanged path) and otherwise mints +
|
|
144
|
+
// registers one, so the POST always carries the non-empty `session_id` the server requires
|
|
145
|
+
// instead of being accepted with a 200 and discarded.
|
|
146
|
+
const sessionId = ensureCurrentSessionId();
|
|
135
147
|
const event: ClientEvent = {
|
|
136
148
|
event_type: "app_event",
|
|
137
149
|
session_id: sessionId,
|
|
@@ -36,7 +36,8 @@ import {
|
|
|
36
36
|
resolveUserContext,
|
|
37
37
|
type WireUserContext,
|
|
38
38
|
} from "../context/userContext";
|
|
39
|
-
import {
|
|
39
|
+
import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
40
|
+
import { detectAppVersion } from "../device/appVersion";
|
|
40
41
|
import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
|
|
41
42
|
|
|
42
43
|
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
@@ -63,8 +64,14 @@ export type CreateAnalyticsConfig = {
|
|
|
63
64
|
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
64
65
|
apiKey: string;
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
67
|
+
* ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
|
|
68
|
+
* this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
|
|
69
|
+
* following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
|
|
70
|
+
* analytics then collapse onto a single device-scoped session — one "first open", forever.
|
|
71
|
+
*
|
|
72
|
+
* OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
|
|
73
|
+
* falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
|
|
74
|
+
* your host runs its own session lifecycle and owns the id the server should correlate on.
|
|
68
75
|
*/
|
|
69
76
|
sessionId?: string;
|
|
70
77
|
/** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
|
|
@@ -162,6 +169,22 @@ export const createAnalytics = (
|
|
|
162
169
|
const resolveSessionId = (): string =>
|
|
163
170
|
config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
|
|
164
171
|
|
|
172
|
+
// A frozen id is almost always a mistake (it silently flattens every open into ONE session), so
|
|
173
|
+
// name it once at construction — same dev-only channel as the email-shape guard below.
|
|
174
|
+
if (config.sessionId) {
|
|
175
|
+
warnInDev(
|
|
176
|
+
"[wireai] createAnalytics({ sessionId }) PINS every event from this instance to that one " +
|
|
177
|
+
"frozen id and opts out of the live per-open session (app.session_started) — lifecycle " +
|
|
178
|
+
"analytics collapse onto a single device-scoped id. Remove it unless your host runs its " +
|
|
179
|
+
"own session lifecycle.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The auto-detected host app version, read ONCE here (cheap, sync, never throws). It backs the
|
|
184
|
+
// `user_context.app_version` fallback below: without it a host that passes no `config.appVersion`
|
|
185
|
+
// got the detected version on `device.appVersion` only, leaving the user_context field absent.
|
|
186
|
+
const detectedAppVersion = detectAppVersion();
|
|
187
|
+
|
|
165
188
|
// The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
|
|
166
189
|
// every event so a post-mount update (login) takes effect immediately. Declared before the envelope
|
|
167
190
|
// provider so the provider can read the current `userContext.appVersion` (see below).
|
|
@@ -177,21 +200,18 @@ export const createAnalytics = (
|
|
|
177
200
|
typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
|
|
178
201
|
? config.userContext.deviceKey.trim()
|
|
179
202
|
: undefined;
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
})
|
|
193
|
-
.catch(() => {});
|
|
194
|
-
}
|
|
203
|
+
// The auto id comes from the ONE process-wide registry (`resolveAutoDeviceKey`), NOT a mint local to
|
|
204
|
+
// this instance. A host that also builds a `createWireActivation` instance used to get a SECOND,
|
|
205
|
+
// different auto id for the same install, splitting `device_key` across two id spaces — see the
|
|
206
|
+
// registry note in context/deviceId.ts. Resolved lazily per event so hydration is picked up.
|
|
207
|
+
const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
|
|
208
|
+
appId: config.appId,
|
|
209
|
+
// A host-supplied deviceKey opts out of minting AND persisting (unchanged contract).
|
|
210
|
+
storage: hostDeviceKeyAtInit ? undefined : config.storage,
|
|
211
|
+
};
|
|
212
|
+
// Start hydration AT CONSTRUCTION (not at the first event) so the persisted id is adopted as early
|
|
213
|
+
// as it used to be. The return value is deliberately discarded — every event re-resolves.
|
|
214
|
+
if (!hostDeviceKeyAtInit) resolveAutoDeviceKey(autoDeviceKeyOptions);
|
|
195
215
|
|
|
196
216
|
// A provider (not a fixed value) so `networkType`, the current session id, AND the effective app
|
|
197
217
|
// version are evaluated fresh on every enqueue. An explicit `WireUserContext.appVersion` (a host that
|
|
@@ -241,8 +261,9 @@ export const createAnalytics = (
|
|
|
241
261
|
? userContext.deviceKey
|
|
242
262
|
: undefined;
|
|
243
263
|
const resolved = resolveUserContext(
|
|
244
|
-
|
|
245
|
-
{
|
|
264
|
+
// `??` is lazy on purpose: a host-supplied key must never even touch the auto registry.
|
|
265
|
+
{ ...userContext, deviceKey: hostDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions) },
|
|
266
|
+
{ autoAppVersion: config.appVersion ?? detectedAppVersion },
|
|
246
267
|
);
|
|
247
268
|
if (resolved.userContext) {
|
|
248
269
|
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
@@ -32,7 +32,22 @@
|
|
|
32
32
|
* PROCESS-LOCAL, NOT PERSISTED: the slot lives on the runtime global, so it tracks the CURRENT
|
|
33
33
|
* process's open and a fresh open overwrites it. There is no cross-launch state.
|
|
34
34
|
* `resetCurrentSessionId` clears the slot so a unit test starts from a clean registry.
|
|
35
|
+
*
|
|
36
|
+
* ── WHY `ensureCurrentSessionId` EXISTS (the silent-drop contract) ─────────────────────────────
|
|
37
|
+
* The server's event model declares `session_id: str = Field(min_length=1)` — REQUIRED, non-empty.
|
|
38
|
+
* `POST /v1/events` validates each event inside a try/except that increments a `skipped` counter and
|
|
39
|
+
* still returns HTTP 200. So an event posted without a `session_id` is accepted by the wire and
|
|
40
|
+
* DISCARDED by the server, and a fire-and-forget client can never learn it happened. That is the
|
|
41
|
+
* worst of both: no error, no data. Screen tracking in a host that never mounted the lifecycle hook
|
|
42
|
+
* fell into exactly that hole — every screen view posted, 200'd, and dropped.
|
|
43
|
+
*
|
|
44
|
+
* `ensureCurrentSessionId` closes it: it returns the registered id when an open HAS been registered
|
|
45
|
+
* (unchanged behaviour for every host that fires `reportSessionStart` first), and otherwise mints one,
|
|
46
|
+
* REGISTERS it, and returns it — so every later event in the process correlates to that same id
|
|
47
|
+
* instead of each emitting its own orphan. A minted id is a fallback, not a substitute for a real
|
|
48
|
+
* app-open: it warns once in dev, naming the fix.
|
|
35
49
|
*/
|
|
50
|
+
import { makeSessionId } from "./reportClientEvent";
|
|
36
51
|
|
|
37
52
|
/**
|
|
38
53
|
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
@@ -67,3 +82,71 @@ export const getCurrentSessionId = (): string | undefined =>
|
|
|
67
82
|
export const resetCurrentSessionId = (): void => {
|
|
68
83
|
globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
|
|
69
84
|
};
|
|
85
|
+
|
|
86
|
+
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
87
|
+
declare const __DEV__: boolean | undefined;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
|
|
91
|
+
* Same idiom as `analyticsFacade.warnInDev` — deliberately duplicated rather than imported, so this
|
|
92
|
+
* module keeps its zero-import-weight for the tree-shaken analytics bundle.
|
|
93
|
+
*
|
|
94
|
+
* Returns whether it ACTUALLY warned, so the caller's once-flag is spent on a warning a developer
|
|
95
|
+
* saw. Marking "already warned" after a no-op would burn the single warning in prod, and the one
|
|
96
|
+
* dev build that needed it would then run silent.
|
|
97
|
+
*/
|
|
98
|
+
const warnInDev = (message: string): boolean => {
|
|
99
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
100
|
+
console.warn(message);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
|
|
107
|
+
const MINT_WARNING =
|
|
108
|
+
"[wireai] No app-open session was registered, so a session id was minted for this event " +
|
|
109
|
+
"(the server drops an event that has no session_id, and still answers 200). Mount " +
|
|
110
|
+
"useLifecycleEvents at your app root so events correlate to a real app-open.";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* "Have we already warned about a minted session id?" — its OWN `Symbol.for` slot, for the same
|
|
114
|
+
* cross-bundle reason as the id itself: a plain module `let` would warn once per inlined copy, i.e.
|
|
115
|
+
* once per bundle, not once per process. NOT cleared by `resetCurrentSessionId`: "warn once" is a
|
|
116
|
+
* process-lifetime promise, and a test that resets the id between mints is still one process.
|
|
117
|
+
*/
|
|
118
|
+
const MINT_WARNED_SLOT: unique symbol = Symbol.for(
|
|
119
|
+
"@wireai/activation:currentSessionIdMintWarned",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
type GlobalWithWarnSlot = typeof globalThis & { [MINT_WARNED_SLOT]?: boolean };
|
|
123
|
+
|
|
124
|
+
const warnSlot = globalThis as GlobalWithWarnSlot;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The current per-open `session_id`, MINTING and registering one when no app-open has been
|
|
128
|
+
* registered yet. Always returns a non-empty string. Idempotent (a second call returns the same id)
|
|
129
|
+
* and never throws.
|
|
130
|
+
*
|
|
131
|
+
* Use this on every path that puts a `session_id` on the wire. The server REQUIRES a non-empty
|
|
132
|
+
* `session_id` and drops the event otherwise while still answering 200 (see the module header), so
|
|
133
|
+
* "no id yet" must never mean "send it without one".
|
|
134
|
+
*
|
|
135
|
+
* BACKWARD-COMPATIBLE BY CONSTRUCTION: when `reportSessionStart` / `useLifecycleEvents` has already
|
|
136
|
+
* registered the real per-open id, this is `getCurrentSessionId()` and nothing changes. It only ever
|
|
137
|
+
* mints in the case that used to produce a silently discarded event.
|
|
138
|
+
*
|
|
139
|
+
* A mint means the host never registered an app-open, so the minted id is one the server has not
|
|
140
|
+
* seen a `session_started` for — the events land, but the session is thinner than a real open.
|
|
141
|
+
* Hence the one-time dev warning naming the fix (mount `useLifecycleEvents` at the app root).
|
|
142
|
+
*/
|
|
143
|
+
export const ensureCurrentSessionId = (): string => {
|
|
144
|
+
const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
145
|
+
if (typeof existing === "string" && existing.length > 0) return existing;
|
|
146
|
+
const minted = makeSessionId();
|
|
147
|
+
globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
|
|
148
|
+
if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
|
|
149
|
+
warnSlot[MINT_WARNED_SLOT] = true;
|
|
150
|
+
}
|
|
151
|
+
return minted;
|
|
152
|
+
};
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
* `notifyOnline()`; persistence is the host-injected AsyncStorage-compatible subset.
|
|
25
25
|
*/
|
|
26
26
|
import type { ContextEnvelope } from "./contextEnvelope";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
buildEventsRequest,
|
|
29
|
+
warnOnSkippedEvents,
|
|
30
|
+
type ClientEvent,
|
|
31
|
+
type ClientEventTarget,
|
|
32
|
+
} from "./reportClientEvent";
|
|
28
33
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
29
34
|
|
|
30
35
|
/** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
|
|
@@ -241,6 +246,9 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
241
246
|
const timer = setTimeout(() => controller?.abort(), 15_000);
|
|
242
247
|
try {
|
|
243
248
|
const res = await fetch(req.url, { ...req.init, signal: controller?.signal });
|
|
249
|
+
// A 200 can still carry `skipped:N` — events the server threw away. Log-only: the ack below
|
|
250
|
+
// stays `res.ok`, so retry/dequeue behaviour is unchanged.
|
|
251
|
+
warnOnSkippedEvents(res);
|
|
244
252
|
return !!(res && (res as { ok?: boolean }).ok);
|
|
245
253
|
} catch {
|
|
246
254
|
return false;
|
package/src/analytics/index.ts
CHANGED
|
@@ -73,5 +73,24 @@ export {
|
|
|
73
73
|
export type { ClearUserContextOptions } from "../context/userContext";
|
|
74
74
|
export { looksLikeEmail } from "../identity/userIdentity";
|
|
75
75
|
|
|
76
|
+
// ─── The ONE auto-minted, persisted per-install `device_key` (the join key) ────
|
|
77
|
+
// Public so an analytics-only consumer can read the SAME id the façade stamps and forward it to the
|
|
78
|
+
// onboarding side via `activationJoinContext(deviceKey)`. Without it the join has no reachable key.
|
|
79
|
+
export {
|
|
80
|
+
resolveAutoDeviceKey,
|
|
81
|
+
resetAutoDeviceKeys,
|
|
82
|
+
deviceIdStorageKey,
|
|
83
|
+
AUTO_DEVICE_ID_PREFIX,
|
|
84
|
+
} from "../context/deviceId";
|
|
85
|
+
export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "../context/deviceId";
|
|
86
|
+
|
|
76
87
|
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
77
|
-
export {
|
|
88
|
+
export {
|
|
89
|
+
getCurrentSessionId,
|
|
90
|
+
// The write-through read every wire path uses: returns the registered per-open id, minting +
|
|
91
|
+
// registering one when no app-open has been registered (the server drops an event with no
|
|
92
|
+
// `session_id` and still answers 200).
|
|
93
|
+
ensureCurrentSessionId,
|
|
94
|
+
setCurrentSessionId,
|
|
95
|
+
resetCurrentSessionId,
|
|
96
|
+
} from "./currentSession";
|
|
@@ -128,6 +128,44 @@ 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
|
+
export const warnOnSkippedEvents = (res: unknown): void => {
|
|
146
|
+
try {
|
|
147
|
+
const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
|
|
148
|
+
if (typeof json !== "function") return;
|
|
149
|
+
void Promise.resolve(json.call(res))
|
|
150
|
+
.then((body) => {
|
|
151
|
+
const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
|
|
152
|
+
if (typeof skipped !== "number" || skipped <= 0) return;
|
|
153
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
154
|
+
console.warn(
|
|
155
|
+
`[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
|
|
156
|
+
"(skipped in the response body) — they are gone, not retried. The usual cause is an " +
|
|
157
|
+
"event with a missing or empty session_id.",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
.catch(() => {
|
|
162
|
+
// Unreadable / already-consumed body — best-effort logging, swallow.
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
// A hostile response object — swallow.
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
131
169
|
/**
|
|
132
170
|
* POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
|
|
133
171
|
* 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
|
};
|