@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
|
@@ -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
|
+
}
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -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
|
|
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
|
+
};
|
package/src/reviews/transport.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
})
|
|
166
|
-
|
|
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
|
|
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
|
|
195
|
+
deviceKey: resolveDeviceKey(cfg, opts),
|
|
170
196
|
sessionCount: opts.sessionCount,
|
|
171
197
|
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
172
198
|
platform: Platform.OS,
|