@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.
Files changed (58) hide show
  1. package/AGENTS.md +51 -0
  2. package/CHANGELOG.md +58 -3
  3. package/INTEGRATION_PROMPT.md +13 -1
  4. package/README.md +73 -0
  5. package/dist/analytics/index.d.mts +17 -6
  6. package/dist/analytics/index.d.ts +17 -6
  7. package/dist/analytics/index.js +130 -35
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +126 -36
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-D6RiVtc8.d.ts} +88 -30
  12. package/dist/{currentSession-C0_odnIW.d.mts → currentSession-DsSDHqor.d.mts} +88 -30
  13. package/dist/index.d.mts +236 -36
  14. package/dist/index.d.ts +236 -36
  15. package/dist/index.js +281 -20
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +269 -21
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +49 -6
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +49 -6
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +2 -2
  26. package/dist/reviews/index.d.ts +2 -2
  27. package/dist/reviews/index.js +73 -8
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +73 -8
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
  32. package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
  33. package/llms.txt +1 -0
  34. package/package.json +1 -1
  35. package/src/activation/useWireActivation.ts +12 -1
  36. package/src/activation/wireActivation.ts +36 -24
  37. package/src/analytics/analyticsFacade.ts +41 -20
  38. package/src/analytics/currentSession.ts +83 -0
  39. package/src/analytics/eventQueue.ts +9 -1
  40. package/src/analytics/index.ts +20 -1
  41. package/src/analytics/reportClientEvent.ts +38 -0
  42. package/src/analytics/screenTracking.ts +6 -1
  43. package/src/analytics/useAnalytics.ts +22 -1
  44. package/src/context/deviceId.ts +109 -0
  45. package/src/context/userContext.ts +18 -0
  46. package/src/index.ts +45 -1
  47. package/src/questionnaire/runtime.ts +12 -2
  48. package/src/questionnaire/useQuestionnaireGate.ts +9 -7
  49. package/src/revenuecat/index.ts +55 -0
  50. package/src/revenuecat/purchaseEvents.ts +167 -0
  51. package/src/revenuecat/revenueCatBridge.ts +221 -0
  52. package/src/revenuecat/types.ts +95 -0
  53. package/src/reviews/runtime.ts +92 -1
  54. package/src/reviews/transport.ts +21 -2
  55. package/src/reviews/useReviewGate.ts +12 -7
  56. package/src/session-analytics/lifecycle.ts +9 -2
  57. package/src/session-analytics/reportSessionStart.ts +15 -4
  58. package/src/session-analytics/useLifecycleEvents.ts +28 -2
@@ -0,0 +1,221 @@
1
+ /**
2
+ * revenueCatBridge - the short, first-class path from RevenueCat to Wire activation.
3
+ *
4
+ * const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
5
+ * revenuecat.paywallShown(offering, { source: variant });
6
+ * revenuecat.checkoutStarted(pkg, { source: variant });
7
+ * const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
8
+ *
9
+ * WHAT IT REPLACES: every consumer was hand-rolling the same four things around its paywall,
10
+ * differently: its own event names, its own idea of which RevenueCat fields are safe to send, its
11
+ * own entitlement check duplicated at the purchase and the restore site, and its own plan-tier
12
+ * user property. This owns all four, so adopting the purchase funnel is a constructor plus five
13
+ * call sites.
14
+ *
15
+ * ── THE JOIN KEY (read this before you wire it) ───────────────────────────────────────────────
16
+ * A purchase event is only worth anything if it can be joined to the SAME user's onboarding. The
17
+ * key that does that is `user_context.device_key`, NOT `session_id`:
18
+ *
19
+ * • Purchase side: this bridge reports through a kit sink (`createAnalytics` or
20
+ * `createWireActivation`). Both auto-mint a stable, persisted, per-install `device_key` and
21
+ * stamp it on every event (see `context/deviceId.ts`).
22
+ * • Onboarding side: `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`. The kit
23
+ * forwards `userContext` verbatim into the A2A session-start metadata, and the server records
24
+ * it on the session's `session_started` event, where the SAME `device_key` lookup reads it.
25
+ *
26
+ * Pass the SAME `deviceKey` to both. `session_id` is NOT a join key across these two families: the
27
+ * onboarding session id is the A2A `contextId`, the app-event session id is the per-open id, and
28
+ * they live in disjoint spaces on purpose. Intersecting them yields zero rows, every time.
29
+ *
30
+ * PURE + dependency-free: no React, no React Native, no `react-native-purchases`. Fire-and-forget
31
+ * throughout, so an analytics failure can never take the paywall down with it.
32
+ */
33
+ import type { Analytics } from "../analytics/analyticsFacade";
34
+ import type { WireActivation } from "../activation/wireActivation";
35
+ import {
36
+ PLAN_TIER_CONTEXT_KEY,
37
+ WIRE_PURCHASE_EVENTS,
38
+ describeEntitlement,
39
+ describeFailure,
40
+ describePackage,
41
+ resolvePlanTier,
42
+ type PlanTier,
43
+ } from "./purchaseEvents";
44
+ import type {
45
+ RevenueCatCustomerInfoLike,
46
+ RevenueCatOfferingLike,
47
+ RevenueCatPackageLike,
48
+ RevenueCatSink,
49
+ } from "./types";
50
+
51
+ /**
52
+ * COMPILE-TIME PROOF that both kit sinks satisfy {@link RevenueCatSink}, so a host can pass the
53
+ * analytics instance it already holds with no adapter of its own. Type-only (erased at build), and
54
+ * `tsc --noEmit` fails here the day either surface drifts. This lives in source, not the test file,
55
+ * because `tsconfig.json` excludes tests from the typecheck.
56
+ */
57
+ type AssertIsSink<T extends RevenueCatSink> = T;
58
+ type _AnalyticsIsASink = AssertIsSink<Analytics>;
59
+ type _WireActivationIsASink = AssertIsSink<WireActivation>;
60
+
61
+ /** Small, non-PII extras a host merges onto a purchase event (paywall variant, entry point, ...). */
62
+ export type PurchaseProps = Record<string, string | number | boolean>;
63
+
64
+ /** Inputs for {@link createRevenueCatBridge}. */
65
+ export interface RevenueCatBridgeConfig {
66
+ /**
67
+ * Where events go: the `createAnalytics(...)` instance (offline-first queue) or the
68
+ * `createWireActivation(...)` instance (awaitable POST). Both already carry the `device_key` this
69
+ * funnel joins on, so pass the one the app already holds rather than creating a second.
70
+ */
71
+ analytics: RevenueCatSink;
72
+ /**
73
+ * The entitlement identifier that means "paid" in THIS app, exactly as configured in the
74
+ * RevenueCat dashboard (e.g. `"pro"`). The bridge reads `customerInfo.entitlements.active[id]`.
75
+ */
76
+ entitlementId: string;
77
+ /** Non-PII props merged into EVERY event from this bridge. A per-call prop of the same name wins. */
78
+ commonProps?: PurchaseProps;
79
+ /**
80
+ * Whether entitlement changes also update the bound user context with `plan_tier`
81
+ * (`user_context["custom.plan_tier"]`), so the whole funnel can be sliced paid vs trial vs free.
82
+ * Default `true`. Requires a sink with `setUserContext` (`createAnalytics`); a no-op otherwise.
83
+ */
84
+ writePlanTier?: boolean;
85
+ }
86
+
87
+ /** The bridge surface. Every method is fire-and-forget and never throws. */
88
+ export interface RevenueCatBridge {
89
+ /**
90
+ * The paywall became visible. Pass the RevenueCat offering you just loaded and the bridge derives
91
+ * `offering_id` + `packages_count`. Fires even with no offering, because a paywall that reached
92
+ * the user with nothing to sell is exactly the failure you want in the funnel.
93
+ */
94
+ paywallShown(offering?: RevenueCatOfferingLike, props?: PurchaseProps): void;
95
+ /** The user tapped buy and the store sheet is opening. */
96
+ checkoutStarted(pkg: RevenueCatPackageLike, props?: PurchaseProps): void;
97
+ /**
98
+ * The store returned from a purchase. Fires `wire_purchase_completed` when the configured
99
+ * entitlement is now active, and `wire_purchase_failed` with `reason: "not_entitled"` when the
100
+ * store confirmed but nothing was granted (the case hosts usually drop on the floor). Syncs
101
+ * `plan_tier` either way. Returns whether the user is entitled now, so the caller can navigate.
102
+ */
103
+ purchaseCompleted(
104
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
105
+ pkg?: RevenueCatPackageLike,
106
+ props?: PurchaseProps,
107
+ ): boolean;
108
+ /** The purchase call rejected. Separates a user cancel from a real store error via `reason`. */
109
+ purchaseFailed(error: unknown, pkg?: RevenueCatPackageLike, props?: PurchaseProps): void;
110
+ /** A restore finished. Reports the resulting tier and returns whether the user is entitled now. */
111
+ purchasesRestored(
112
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
113
+ props?: PurchaseProps,
114
+ ): boolean;
115
+ /**
116
+ * Write the current tier to the bound user context WITHOUT emitting an event. Call it once at
117
+ * launch with a `getCustomerInfo()` snapshot so returning subscribers are segmented correctly
118
+ * from their first event of the session. Returns the tier it resolved.
119
+ */
120
+ syncPlanTier(customerInfo: RevenueCatCustomerInfoLike | undefined): PlanTier;
121
+ }
122
+
123
+ /**
124
+ * Create a bridge bound to one sink and one entitlement. Pure, React-free, and safe to create at
125
+ * module scope next to the paywall.
126
+ */
127
+ export const createRevenueCatBridge = (config: RevenueCatBridgeConfig): RevenueCatBridge => {
128
+ const { analytics, entitlementId } = config;
129
+ const writeTier = config.writePlanTier !== false;
130
+
131
+ /**
132
+ * Send one event. Swallows a throwing sink AND a rejected promise from an awaitable one, so an
133
+ * analytics failure is never visible at the paywall and never becomes an unhandled rejection.
134
+ */
135
+ const emit = (event: string, props: Record<string, unknown>): void => {
136
+ try {
137
+ const result = analytics.track(event, { ...config.commonProps, ...props });
138
+ if (result && typeof (result as Promise<boolean>).catch === "function") {
139
+ void (result as Promise<boolean>).catch(() => {});
140
+ }
141
+ } catch {
142
+ /* analytics must never break a purchase */
143
+ }
144
+ };
145
+
146
+ const syncPlanTier = (customerInfo: RevenueCatCustomerInfoLike | undefined): PlanTier => {
147
+ const tier = resolvePlanTier(customerInfo, entitlementId);
148
+ if (!writeTier) return tier;
149
+ try {
150
+ analytics.setUserContext?.({ extra: { [PLAN_TIER_CONTEXT_KEY]: tier } });
151
+ } catch {
152
+ /* a sink without a usable context writer is not a purchase failure */
153
+ }
154
+ return tier;
155
+ };
156
+
157
+ const paywallShown = (offering?: RevenueCatOfferingLike, props?: PurchaseProps): void => {
158
+ const shown: Record<string, unknown> = {
159
+ packages_count: offering?.availablePackages?.length ?? 0,
160
+ };
161
+ if (typeof offering?.identifier === "string") shown.offering_id = offering.identifier;
162
+ emit(WIRE_PURCHASE_EVENTS.paywallShown, { ...shown, ...props });
163
+ };
164
+
165
+ const checkoutStarted = (pkg: RevenueCatPackageLike, props?: PurchaseProps): void => {
166
+ emit(WIRE_PURCHASE_EVENTS.checkoutStarted, { ...describePackage(pkg), ...props });
167
+ };
168
+
169
+ const purchaseCompleted = (
170
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
171
+ pkg?: RevenueCatPackageLike,
172
+ props?: PurchaseProps,
173
+ ): boolean => {
174
+ const tier = syncPlanTier(customerInfo);
175
+ const entitled = tier !== "free";
176
+ if (entitled) {
177
+ emit(WIRE_PURCHASE_EVENTS.purchased, {
178
+ ...describePackage(pkg),
179
+ ...describeEntitlement(customerInfo, entitlementId),
180
+ ...props,
181
+ });
182
+ return true;
183
+ }
184
+ // The store said yes but the entitlement is not active: a product/entitlement mapping problem,
185
+ // the single most common silent paywall defect. Report it rather than returning quietly.
186
+ emit(WIRE_PURCHASE_EVENTS.purchaseFailed, {
187
+ ...describePackage(pkg),
188
+ reason: "not_entitled",
189
+ code: "unknown",
190
+ entitlement: entitlementId,
191
+ ...props,
192
+ });
193
+ return false;
194
+ };
195
+
196
+ const purchaseFailed = (
197
+ error: unknown,
198
+ pkg?: RevenueCatPackageLike,
199
+ props?: PurchaseProps,
200
+ ): void => {
201
+ emit(WIRE_PURCHASE_EVENTS.purchaseFailed, {
202
+ ...describePackage(pkg),
203
+ ...describeFailure(error),
204
+ ...props,
205
+ });
206
+ };
207
+
208
+ const purchasesRestored = (
209
+ customerInfo: RevenueCatCustomerInfoLike | undefined,
210
+ props?: PurchaseProps,
211
+ ): boolean => {
212
+ const tier = syncPlanTier(customerInfo);
213
+ emit(WIRE_PURCHASE_EVENTS.restored, {
214
+ ...describeEntitlement(customerInfo, entitlementId),
215
+ ...props,
216
+ });
217
+ return tier !== "free";
218
+ };
219
+
220
+ return { paywallShown, checkoutStarted, purchaseCompleted, purchaseFailed, purchasesRestored, syncPlanTier };
221
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * types - STRUCTURAL mirrors of the `react-native-purchases` shapes this adapter READS.
3
+ *
4
+ * WHY structural and not an import: `react-native-purchases` is a NATIVE module. Importing it
5
+ * (even as an optional peer) would put a native rebuild in the kit's dependency story, which the
6
+ * kit's hard rule forbids. Every field below is a subset of the real 9.6.x typings
7
+ * (`@revenuecat/purchases-typescript-internal`: `CustomerInfo`, `PurchasesEntitlementInfo`,
8
+ * `PurchasesPackage`, `PurchasesStoreProduct`, `PurchasesError`), declared as plain `string` /
9
+ * `number` / `boolean` so a host can pass its REAL RevenueCat objects straight in: RevenueCat's
10
+ * `Store`, `PACKAGE_TYPE`, `PERIOD_TYPE` and `PURCHASES_ERROR_CODE` are all STRING enums, and a
11
+ * string enum member is assignable to `string`.
12
+ *
13
+ * The adapter only ever reads. It never constructs a RevenueCat object and never calls the SDK.
14
+ */
15
+
16
+ /** One entitlement as RevenueCat reports it (subset of `PurchasesEntitlementInfo`). */
17
+ export interface RevenueCatEntitlementLike {
18
+ /** The entitlement identifier configured in the RevenueCat dashboard, e.g. `"pro"`. */
19
+ readonly identifier: string;
20
+ /** True while the customer has access. */
21
+ readonly isActive: boolean;
22
+ /** `"NORMAL" | "INTRO" | "TRIAL" | "PREPAID"` on the real type; compared case-insensitively. */
23
+ readonly periodType?: string;
24
+ /** True when the underlying subscription renews at the end of the period. */
25
+ readonly willRenew?: boolean;
26
+ /** The store the entitlement was unlocked from, e.g. `"APP_STORE"`. */
27
+ readonly store?: string;
28
+ /** The product id that unlocked the entitlement. */
29
+ readonly productIdentifier?: string;
30
+ /** False when the purchase is a production one. */
31
+ readonly isSandbox?: boolean;
32
+ }
33
+
34
+ /** The customer snapshot RevenueCat returns from a purchase, a restore, or `getCustomerInfo`. */
35
+ export interface RevenueCatCustomerInfoLike {
36
+ readonly entitlements: {
37
+ /** Active entitlements keyed by identifier. Missing key means "not entitled". */
38
+ readonly active: { readonly [key: string]: RevenueCatEntitlementLike | undefined };
39
+ };
40
+ /** Product ids of the customer's active subscriptions. */
41
+ readonly activeSubscriptions?: readonly string[];
42
+ }
43
+
44
+ /** The purchasable product on a package (subset of `PurchasesStoreProduct`). */
45
+ export interface RevenueCatProductLike {
46
+ readonly identifier: string;
47
+ /** Price in the local currency, as a number. */
48
+ readonly price?: number;
49
+ /** ISO currency code of `price`, e.g. `"EUR"`. */
50
+ readonly currencyCode?: string;
51
+ }
52
+
53
+ /** One offering package (subset of `PurchasesPackage`). */
54
+ export interface RevenueCatPackageLike {
55
+ readonly identifier: string;
56
+ /** `"ANNUAL" | "MONTHLY" | ...` on the real type. */
57
+ readonly packageType?: string;
58
+ readonly product: RevenueCatProductLike;
59
+ /** The offering this package belongs to. */
60
+ readonly offeringIdentifier?: string;
61
+ }
62
+
63
+ /** The current offering a paywall renders (subset of `PurchasesOffering`). */
64
+ export interface RevenueCatOfferingLike {
65
+ readonly identifier?: string;
66
+ readonly availablePackages?: readonly RevenueCatPackageLike[];
67
+ }
68
+
69
+ /** The rejected value of a RevenueCat purchase call (subset of `PurchasesError`). */
70
+ export interface RevenueCatErrorLike {
71
+ /** `PURCHASES_ERROR_CODE`, a STRING enum; `"1"` is `PURCHASE_CANCELLED_ERROR`. */
72
+ readonly code?: string;
73
+ readonly message?: string;
74
+ /** Deprecated on the real type but still populated by 9.6.x. */
75
+ readonly userCancelled?: boolean | null;
76
+ }
77
+
78
+ /**
79
+ * The Wire sink the bridge reports through. Deliberately STRUCTURAL so BOTH kit surfaces satisfy
80
+ * it with no adapter of their own:
81
+ * - `createAnalytics(...)` (offline-first queue) matches `track` + `setUserContext`;
82
+ * - `createWireActivation(...)` (awaitable `wire.track`) matches `track`.
83
+ *
84
+ * Both auto-mint and stamp `user_context.device_key` on every event, which is the join key this
85
+ * adapter's whole design rests on (see `joinKey.md` in the module docs / the README section).
86
+ */
87
+ export interface RevenueCatSink {
88
+ /** Report an in-app event. Returns `void` (queued) or `Promise<boolean>` (awaited POST). */
89
+ track(event: string, props?: Record<string, unknown>): void | Promise<boolean>;
90
+ /**
91
+ * Optional: update the bound user context. Present on `createAnalytics`, absent on
92
+ * `createWireActivation` (its config is captured immutably), so entitlement sync is a no-op there.
93
+ */
94
+ setUserContext?(partial: { extra?: Record<string, string | number | boolean> }): void;
95
+ }
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import type { CoachmarkStorage } from "../coachmarks/types";
8
8
  import { getCoachmarkStorage } from "../coachmarks/runtime";
9
+ import { getCurrentSessionId } from "../analytics/currentSession";
10
+ import { makeSessionId } from "../analytics/reportClientEvent";
9
11
 
10
12
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
11
13
  export const reviewSeenKey = (id: string, version?: string): string =>
@@ -14,9 +16,12 @@ export const reviewSeenKey = (id: string, version?: string): string =>
14
16
  /** Last-shown timestamp key (epoch ms), for the cooldown rule. */
15
17
  export const reviewLastShownKey = (id: string): string => `wire_review_${id}_last`;
16
18
 
17
- /** Session-count key, incremented once per gate mount, for the min-sessions rule. */
19
+ /** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
18
20
  export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sessions`;
19
21
 
22
+ /** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
23
+ export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
24
+
20
25
  /** Resolve the storage to use: an explicit override, else the provider-injected singleton. */
21
26
  export const resolveStorage = (
22
27
  override?: CoachmarkStorage | null,
@@ -43,3 +48,89 @@ export const writeInt = (storage: CoachmarkStorage | null, key: string, value: n
43
48
  // Best-effort.
44
49
  }
45
50
  };
51
+
52
+ /** Read a string from sync storage (undefined on a missing/blank/throwing read). */
53
+ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefined => {
54
+ if (!storage) return undefined;
55
+ try {
56
+ const raw = storage.getItem(key);
57
+ return typeof raw === "string" && raw.length > 0 ? raw : undefined;
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ };
62
+
63
+ // ── The "which app-open is this" id ──────────────────────────────────────────────────────────
64
+ //
65
+ // The gates' `minSessions` rule needs a SESSION, and the counter behind it used to increment once
66
+ // per gate MOUNT. A mount is not a session: navigating away from the home feed and back, a tab that
67
+ // unmounts its screen, or React StrictMode's dev double-invoke of the `useState` initializer all
68
+ // bumped it. So `minSessions: 2` — the fail-closed default added after the 2026-07-16 one-star
69
+ // incident — was satisfiable inside the user's FIRST app open, which is the exact thing it exists
70
+ // to prevent. `sessions` was a count of mounts wearing the name of a session.
71
+ //
72
+ // The correct unit already exists: `getCurrentSessionId()`, the per-open id the server sees on
73
+ // `app.session_started`. When a host wires the lifecycle events, the counter keys off THAT and is
74
+ // exactly "distinct app-opens", the same unit the server's own `min_sessions` uses.
75
+ //
76
+ // When no host wired lifecycle events there is no registered open, so we fall back to a PROCESS-
77
+ // scoped id: one JS process is one app launch, which is still a genuine app-open and is strictly
78
+ // closer to a session than a mount is. It lives in a `globalThis` slot keyed by `Symbol.for(...)`
79
+ // for the same reason `currentSession` does: tsup inlines this module into several bundles and a
80
+ // plain module-local `let` would give each bundle its own "process".
81
+ const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
82
+
83
+ type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
84
+
85
+ const openIdGlobal = globalThis as GlobalWithOpenId;
86
+
87
+ /**
88
+ * The id identifying THIS app-open for gate counting: the live per-open `session_id` when a host
89
+ * wired the lifecycle events, else a stable per-process id. Never empty.
90
+ */
91
+ export const currentOpenId = (): string => {
92
+ const live = getCurrentSessionId();
93
+ if (live) return live;
94
+ const existing = openIdGlobal[PROCESS_OPEN_ID_SLOT];
95
+ if (existing) return existing;
96
+ const created = makeSessionId();
97
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = created;
98
+ return created;
99
+ };
100
+
101
+ /** Test-only: forget the process open id so a unit test starts from a clean launch. */
102
+ export const resetProcessOpenId = (): void => {
103
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
104
+ };
105
+
106
+ /**
107
+ * Return the app-open count for the gate, incrementing it AT MOST ONCE per app-open.
108
+ *
109
+ * IDEMPOTENT by construction: the open id that last incremented the counter is stored alongside it,
110
+ * so a second call within the same open (a remount, a StrictMode double-invoke, a second gate render)
111
+ * reads the stored count back instead of bumping it. A new open id bumps exactly once.
112
+ *
113
+ * Storage-less hosts get `1` for the first call and `1` for every later call within the open —
114
+ * degraded but never inflating, which is the safe direction for a fail-closed gate.
115
+ */
116
+ export const bumpSessionCount = (
117
+ storage: CoachmarkStorage | null,
118
+ sessionsKey: string,
119
+ openKey: string,
120
+ openId: string = currentOpenId(),
121
+ ): number => {
122
+ const lastOpen = readStr(storage, openKey);
123
+ const stored = readInt(storage, sessionsKey);
124
+ // Already counted this open → return what we counted, do not bump again.
125
+ if (lastOpen === openId) return stored > 0 ? stored : 1;
126
+ const next = stored + 1;
127
+ writeInt(storage, sessionsKey, next);
128
+ if (storage) {
129
+ try {
130
+ storage.setItem(openKey, openId);
131
+ } catch {
132
+ // Best-effort: a failed write only means this open may be counted twice.
133
+ }
134
+ }
135
+ return next;
136
+ };
@@ -12,6 +12,7 @@
12
12
  * question_key=<name>), which is what the backend review-firing rules evaluate on — and
13
13
  * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
14
14
  */
15
+ import { ensureCurrentSessionId } from "../analytics/currentSession";
15
16
  import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
16
17
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
17
18
 
@@ -157,7 +158,11 @@ export const fetchReviewDecision = async (
157
158
 
158
159
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
159
160
  export interface ReportAppEventOptions {
160
- /** The onboarding/session id to correlate with, when known. */
161
+ /**
162
+ * The onboarding/session id to correlate with, when known. Optional: when omitted the event
163
+ * still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
164
+ * with no `session_id` is dropped server-side behind a 200. Pass one only to override.
165
+ */
161
166
  sessionId?: string;
162
167
  /** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
163
168
  deviceKey?: string;
@@ -171,6 +176,16 @@ export interface ReportAppEventOptions {
171
176
  * stable identifier and `meta` small + non-PII.
172
177
  *
173
178
  * reportAppEvent(target, "content_share", { sessionId, deviceKey });
179
+ *
180
+ * ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
181
+ * The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
182
+ * validates per event inside a try/except that counts the failure as `skipped` and STILL returns
183
+ * HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
184
+ * fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
185
+ * `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
186
+ * posted every screen view into that hole. So the id is no longer conditional — an explicit
187
+ * `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
188
+ * has been registered yet).
174
189
  */
175
190
  export const reportAppEvent = (
176
191
  target: ReviewTarget | undefined,
@@ -179,11 +194,15 @@ export const reportAppEvent = (
179
194
  ): void => {
180
195
  if (!target?.serverUrl || !name) return;
181
196
  try {
197
+ // An explicit id wins; a missing OR BLANK one falls back to the current per-open id. A bare
198
+ // `??` would let `sessionId: ""` through, and the server rejects an empty string exactly like
199
+ // a missing key (`min_length=1`), so the blank case has to fall back too.
200
+ const supplied = options.sessionId ?? "";
182
201
  const event: Record<string, unknown> = {
183
202
  event_type: "app_event",
184
203
  question_key: name,
204
+ session_id: supplied.trim().length > 0 ? supplied : ensureCurrentSessionId(),
185
205
  };
186
- if (options.sessionId) event.session_id = options.sessionId;
187
206
  // device_key rides in the non-PII user_context bucket the server sanitizes; the
188
207
  // review-decision endpoint reads it to group a device's sessions.
189
208
  if (options.deviceKey) event.user_context = { device_key: options.deviceKey };
@@ -19,10 +19,12 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
19
19
  import { decideReview, evaluateGate, resolveRules } from "./decision";
20
20
  import { sameDecision, shallowEqual } from "./equality";
21
21
  import {
22
+ bumpSessionCount,
22
23
  readInt,
23
24
  resolveStorage,
24
25
  reviewLastShownKey,
25
26
  reviewSeenKey,
27
+ reviewSessionOpenKey,
26
28
  reviewSessionsKey,
27
29
  writeInt,
28
30
  } from "./runtime";
@@ -70,14 +72,17 @@ export const useReviewGate = ({
70
72
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
71
73
  const lastKey = reviewLastShownKey(config.id);
72
74
  const sessionsKey = reviewSessionsKey(config.id);
75
+ const sessionOpenKey = reviewSessionOpenKey(config.id);
73
76
 
74
- // Read (and bump) the session counter ONCE per mount: this mount is a new session.
75
- const sessions = useState(() => {
76
- const store = resolveStorage(storage);
77
- const next = readInt(store, sessionsKey) + 1;
78
- writeInt(store, sessionsKey, next);
79
- return next;
80
- })[0];
77
+ // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
78
+ // keys off the live per-open session id (or a per-process id when no host wired the lifecycle
79
+ // events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
80
+ // initializer all read the same number back instead of inflating it. Before this, `sessions`
81
+ // counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
82
+ // very first app open — the exact scenario it was added to prevent.
83
+ const sessions = useState(() =>
84
+ bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
85
+ )[0];
81
86
 
82
87
  // Gate the local rules behind an optional client-side timeout, so a reachable server
83
88
  // gets a window to answer first. A present `decision` bypasses the wait entirely.
@@ -212,11 +212,18 @@ export interface WireLifecycleOptions extends ReportFirstOpenOptions {}
212
212
  * Route both through the same `sink` (the offline queue) to buffer them. Fire-and-forget.
213
213
  */
214
214
  export const wireLifecycleEvents = (opts: WireLifecycleOptions): void => {
215
- reportFirstOpen(opts);
215
+ // ONE per-open id for BOTH events when the caller supplies none. Without this, `reportFirstOpen`
216
+ // fell through to `buildLifecycleEvent`'s own `makeSessionId()` while `reportSessionStart` minted a
217
+ // DIFFERENT one — so `app.first_open` carried a session id the server never saw a `session_started`
218
+ // for and back-filled into a phantom session, inflating session counts. `useLifecycleEvents` already
219
+ // pinned this (it mints `mountOpenSessionId`); the React-free entry point did not, so the identical
220
+ // bug was still reachable from the documented non-hook path.
221
+ const sessionId = opts.sessionId ?? makeSessionId();
222
+ reportFirstOpen({ ...opts, sessionId });
216
223
  reportSessionStart({
217
224
  target: opts.target,
218
225
  sink: opts.sink,
219
- sessionId: opts.sessionId,
226
+ sessionId,
220
227
  userId: opts.userId,
221
228
  deviceKey: opts.deviceKey,
222
229
  sessionCount: opts.sessionCount,
@@ -32,7 +32,12 @@
32
32
  * never be able to break the app.
33
33
  */
34
34
  import { setCurrentSessionId } from "../analytics/currentSession";
35
- import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
+ import {
36
+ makeSessionId,
37
+ warnOnSkippedEvents,
38
+ type ClientEvent,
39
+ type ClientEventTarget,
40
+ } from "../analytics/reportClientEvent";
36
41
  import type { DeviceContext } from "../device/deviceContext";
37
42
  import { sanitizeUserId } from "../identity/userIdentity";
38
43
 
@@ -162,9 +167,15 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
162
167
  method: "POST",
163
168
  headers,
164
169
  body: JSON.stringify({ events: [event] }),
165
- }).catch(() => {
166
- // Network/transport error — analytics is best-effort, swallow.
167
- });
170
+ })
171
+ .then((res) => {
172
+ // A 200 can still carry `skipped:N` — the server took the request and threw the event away.
173
+ // Log-only; this path has nothing to retry either way.
174
+ warnOnSkippedEvents(res);
175
+ })
176
+ .catch(() => {
177
+ // Network/transport error — analytics is best-effort, swallow.
178
+ });
168
179
  } catch {
169
180
  // URL construction, JSON serialization, a throwing sink, or a missing fetch — swallow.
170
181
  }
@@ -29,6 +29,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
29
29
 
30
30
  import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
31
31
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
32
+ import { resolveAutoDeviceKey } from "../context/deviceId";
32
33
  import { collectDeviceContext } from "../device/deviceContext";
33
34
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
35
  import { reportFirstOpen } from "./lifecycle";
@@ -114,6 +115,31 @@ export const useLifecycleEvents = (
114
115
  const targetOf = (cfg: LifecycleConfig | undefined): ClientEventTarget | undefined =>
115
116
  cfg?.serverUrl ? { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" } : undefined;
116
117
 
118
+ /**
119
+ * The `device_key` these lifecycle events ride under. A host-supplied id always wins.
120
+ *
121
+ * WHY THE FALLBACK EXISTS: `min_sessions` (the review / questionnaire firing rule, and the
122
+ * "fire on the user's Nth session" recipe in the README) is computed SERVER-SIDE by counting
123
+ * distinct `app.session_started` events grouped by `user_context.device_key`. A host that took
124
+ * the batteries-included path and passed no `deviceKey` emitted those events with NO device key
125
+ * at all — while its `createAnalytics` / `createWireActivation` events carried an auto-minted
126
+ * one. Two disjoint identity spaces again: the counter the rule reads could never increase, so
127
+ * `min_sessions` was structurally unsatisfiable and the gate never fired from the server side.
128
+ *
129
+ * ONLY WITH `storage`: the auto id is per-INSTALL only when it can be persisted. With no
130
+ * storage it would be per-LAUNCH, which would make every open look like a brand-new device and
131
+ * corrupt `min_sessions` in the other direction. So no storage → no fallback, same as before.
132
+ */
133
+ const resolveDeviceKey = (
134
+ cfg: LifecycleConfig | undefined,
135
+ opts: UseLifecycleEventsOptions,
136
+ ): string | undefined => {
137
+ const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
138
+ if (host) return host;
139
+ if (!cfg?.storage) return undefined;
140
+ return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
141
+ };
142
+
117
143
  // ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
118
144
  // WHY: `first_open` used to default to its OWN fresh `makeSessionId()` (via `buildLifecycleEvent`)
119
145
  // while `session_started` minted a different one — so every install's `first_open` carried a
@@ -138,7 +164,7 @@ export const useLifecycleEvents = (
138
164
  sink: resolveSink(),
139
165
  sessionId,
140
166
  userId: opts.userId,
141
- deviceKey: opts.deviceKey,
167
+ deviceKey: resolveDeviceKey(cfg, opts),
142
168
  sessionCount: opts.sessionCount,
143
169
  appVersion: cfg?.appVersion ?? device.appVersion,
144
170
  platform: Platform.OS,
@@ -166,7 +192,7 @@ export const useLifecycleEvents = (
166
192
  storage: cfg?.storage,
167
193
  appId: cfg?.appId,
168
194
  userId: opts.userId,
169
- deviceKey: opts.deviceKey,
195
+ deviceKey: resolveDeviceKey(cfg, opts),
170
196
  sessionCount: opts.sessionCount,
171
197
  appVersion: cfg?.appVersion ?? device.appVersion,
172
198
  platform: Platform.OS,