@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
package/AGENTS.md CHANGED
@@ -50,6 +50,8 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
50
50
  - `themeFromBrand({ primary })`: derive a full theme from one brand color.
51
51
  - `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
52
52
  - `defaultIllustrations`: dependency-free fallback art; spread your own over it.
53
+ - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
54
+ - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Read the RevenueCat section before you use it.
53
55
  - Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`.
54
56
 
55
57
  ## `<WireOnboarding>` props
@@ -131,6 +133,55 @@ write your own fetch.
131
133
  `createWireActivation(config)`. This is the kit-owned replacement for hand-rolling
132
134
  session-id + await-POST + revalidate.
133
135
 
136
+ ## RevenueCat (the purchase funnel)
137
+
138
+ The app sells subscriptions through `react-native-purchases`? Wire the paywall to the same event
139
+ stream as onboarding. The kit does NOT depend on `react-native-purchases` (it is a native module and
140
+ the kit never forces a rebuild); the adapter types the RevenueCat objects structurally, so you pass
141
+ the real ones you already have.
142
+
143
+ ```tsx
144
+ import { createAnalytics, createRevenueCatBridge, activationJoinContext } from "@wireai/activation";
145
+
146
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
147
+ const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
148
+
149
+ revenuecat.paywallShown(offering, { source: variant });
150
+ revenuecat.checkoutStarted(pkg, { source: variant });
151
+ const entitled = revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant });
152
+ revenuecat.purchaseFailed(error, pkg, { source: variant });
153
+ revenuecat.purchasesRestored(customerInfo);
154
+ revenuecat.syncPlanTier(customerInfo); // at launch, writes plan_tier with no event
155
+ ```
156
+
157
+ Canonical names (they are also the `question_key` strings a firing trigger matches on):
158
+ `wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`, `wire_purchase_failed`,
159
+ `wire_purchase_restored`.
160
+
161
+ **The join key is `user_context.device_key`, never `session_id`.** An onboarding session id is the
162
+ A2A `contextId` and an app-event session id is the per-open id; the two live in separate spaces on
163
+ purpose, so intersecting them returns nothing. Put the SAME device key on both sides: the analytics
164
+ and activation surfaces auto-mint and stamp it on every event, and onboarding gets it from
165
+ `<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`. Never hand-write
166
+ `userContext={{ deviceKey }}`, because the server's device lookup reads `device_key` and a
167
+ misspelled bucket produces an empty funnel instead of an error.
168
+
169
+ **If the host owns no device id, read the kit's.** Do not leave the onboarding side blank.
170
+ `resolveAutoDeviceKey({ appId, storage })` returns the exact id `createAnalytics` /
171
+ `createWireActivation` auto-mint and persist for this install, so both halves of the join agree:
172
+
173
+ ```tsx
174
+ import { resolveAutoDeviceKey, activationJoinContext } from "@wireai/activation";
175
+
176
+ const deviceKey = resolveAutoDeviceKey({ appId, storage }); // the SAME id the analytics side stamps
177
+ <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
178
+ ```
179
+
180
+ Always pass `storage`. Without it the id is per-LAUNCH, not per-install, and a per-launch key makes
181
+ every open look like a new device, which breaks `min_sessions` and A/B arm stickiness as surely as
182
+ no key breaks the join. The same rule governs the lifecycle wiring: `useLifecycleEvents` falls back
183
+ to this id for `app.session_started` / `app.first_open` ONLY when `config.storage` is present.
184
+
134
185
  ## Gotchas (do not miss)
135
186
 
136
187
  - **EAS / cloud builds:** install via **git URL or registry**, never a local `file:` path *outside the app repo* (EAS won't resolve it).
package/CHANGELOG.md CHANGED
@@ -3,10 +3,65 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
- ## [Unreleased]
6
+ ## [0.12.0] — 2026-07-27
7
+
8
+ The release Myelino 2.2.0 pins. Ships the RevenueCat path (#52), the identity/counting audit,
9
+ the session-id fallback contract, and the transport/config fixes behind a silently dead lifecycle
10
+ stream (a consumer's `first_open` read 6 all-time while the emitting code was deployed).
11
+
12
+ ### Added
13
+
14
+ - **RevenueCat path (`createRevenueCatBridge`).** The purchase funnel is now a first-class, documented
15
+ path instead of glue every consumer writes itself. New ROOT exports: `createRevenueCatBridge`,
16
+ `WIRE_PURCHASE_EVENTS` (`wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`,
17
+ `wire_purchase_failed`, `wire_purchase_restored`), the pure mappers (`activeEntitlement`,
18
+ `resolvePlanTier`, `describePackage`, `describeEntitlement`, `isUserCancelled`, `describeFailure`),
19
+ `PLAN_TIER_CONTEXT_KEY`, and the structural RevenueCat types. The bridge reports through the
20
+ `createAnalytics` or `createWireActivation` instance the host already holds (a compile-time check in
21
+ `revenueCatBridge.ts` keeps both assignable), writes `plan_tier` (paid / trial / free) to the bound
22
+ user context, drops the store's localized error `message` and keeps only the stable RevenueCat
23
+ `code`, and reports a store-confirmed purchase that granted no entitlement as
24
+ `wire_purchase_failed` with `reason: "not_entitled"` rather than swallowing it. **No new dependency:
25
+ `react-native-purchases` is a native module and is never imported**, so the RevenueCat objects are
26
+ typed structurally and the host passes its real ones.
27
+ - **`activationJoinContext(deviceKey)`.** The `userContext` value that joins an onboarding session to
28
+ the app's later events. The join key is `user_context.device_key` and NEVER `session_id`: an
29
+ onboarding session id is the A2A `contextId`, an app-event session id is the per-open id, and
30
+ intersecting the two id spaces returns zero rows every time. This helper exists so the wire spelling
31
+ (`device_key`) is decided in one place; a hand-written `userContext={{ deviceKey }}` builds a bucket
32
+ the server's device lookup does not read, which produces a silently empty funnel rather than an
33
+ error. `src/revenuecat/joinKey.test.ts` locks both sides against each other.
34
+ - **One shared auto `device_key` per install.** `createAnalytics` and `createWireActivation` each
35
+ minted their OWN id and raced a storage read, so a host building both (the documented wiring)
36
+ stamped two different `device_key`s on one install — splitting the key that `min_sessions`, A/B arm
37
+ stickiness and the purchase-to-onboarding join all read. Now ONE id lives in a `globalThis` slot
38
+ keyed by `Symbol.for(...)` (the `currentSession` pattern). `useLifecycleEvents` now stamps the
39
+ shared key on lifecycle events too (storage-gated, so a per-launch id never corrupts the counter),
40
+ and `resolveAutoDeviceKey` is exported from the root and `./analytics` barrels.
41
+
42
+ ### Fixed
7
43
 
8
- Architecture-audit fixes. The next release that ships these MUST be at least **0.11.0** (0.10.0 is
9
- published and immutable). One BEHAVIOR CHANGE, called out below.
44
+ - **Every event carries a `session_id`, or the server eats it.** `POST /v1/events` validates per
45
+ event inside a try/except that counts the failure as "skipped" and returns HTTP 200 anyway, so an
46
+ event without a session id was accepted by the wire and discarded. `ensureCurrentSessionId()`
47
+ returns the registered per-open id when there is one and otherwise mints one, REGISTERS it so later
48
+ events join the same session, and warns once per process in `__DEV__` (mount `useLifecycleEvents`
49
+ at the app root). Closes the `reportAppEvent(target, "screen", { deviceKey })` hole and
50
+ `wire.track()`'s refusal to send without a registered open.
51
+ - **`config.sessionId` on `createAnalytics` now warns in dev.** Passing it pins EVERY event from the
52
+ instance to that one frozen id and opts out of the live per-open session — the exact footgun that
53
+ flatlined a consumer's lifecycle metrics. The JSDoc and README now state the freeze semantics
54
+ honestly; omitting it is the correct default.
55
+ - **The persistent transport paths read the `/v1/events` ACK body.** A 200 was never a receipt: the
56
+ server reports discarded events only as `skipped` in the body. The event queue and
57
+ `reportSessionStart` now warn in dev with the skipped count. Log-only — retry/dequeue semantics
58
+ unchanged.
59
+ - **`user_context.app_version` falls back to `detectAppVersion()`** when the host passes nothing, so
60
+ version attribution on the facade path no longer depends on the host forwarding its own version.
61
+
62
+ ## [0.11.0] — 2026-07-21
63
+
64
+ Architecture-audit fixes (#50/#51). One BEHAVIOR CHANGE, called out below.
10
65
 
11
66
  ### Changed (BEHAVIOR) — review gate no longer fires on the first session
12
67
 
@@ -53,7 +53,19 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
53
53
  `useQuestionnaireGate` the same way), and at the action site call `await track("journal_done")`.
54
54
  Do NOT hand-roll the session id or the POST; `wire.track` owns both and revalidates the gate on
55
55
  a successful 2xx.
56
- 9. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
56
+ 9. RevenueCat (only if my app sells subscriptions through `react-native-purchases`): wire my paywall
57
+ to the same event stream with `createRevenueCatBridge({ analytics, entitlementId: "<my
58
+ entitlement, e.g. pro>" })` from `@wireai/activation`. Do NOT add or import
59
+ `react-native-purchases` in the kit path; the adapter types the RevenueCat objects structurally,
60
+ so pass my real `PurchasesOffering` / `PurchasesPackage` / `CustomerInfo` straight in. Replace my
61
+ hand-rolled paywall analytics calls with `paywallShown` / `checkoutStarted` / `purchaseCompleted`
62
+ / `purchaseFailed` / `purchasesRestored`, and call `syncPlanTier(customerInfo)` once at launch.
63
+ THE JOIN KEY: pass the SAME device key to the analytics instance AND to onboarding via
64
+ `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, because purchases join to
65
+ onboarding on `user_context.device_key` and never on `session_id` (those are separate id spaces
66
+ and intersecting them returns zero rows). If my app has no single stable device key yet, say so
67
+ instead of inventing one.
68
+ 10. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
57
69
 
58
70
  Report back: the files you changed (path:line), the typecheck result, and anything you couldn't
59
71
  infer about my conventions.
package/README.md CHANGED
@@ -370,6 +370,24 @@ await clearUserContext({ storage, appId }); // the wire/activation path (then re
370
370
 
371
371
  **3. The auto `device_key` is a per-install identifier.** When you supply no `deviceKey`, the analytics and activation surfaces auto-mint a stable per-install `device_key`, persist it via `storage` (`wireai:analytics:deviceKey:<appId>`), and stamp it on every event so the server's review/questionnaire gating and A/B stickiness work out of the box. It carries no hardware id, no IDFA/GAID, and cannot be joined across apps (the privacy category of a first-party cookie), but it IS a persistent per-install id. If you adopt these surfaces, declare it in your App Privacy / Data Safety accordingly. Supply your own `deviceKey` to override it.
372
372
 
373
+ ### Do not pass `sessionId` to `createAnalytics`
374
+
375
+ `CreateAnalyticsConfig.sessionId` is an opt-out knob, not a default. Set it and **every** event that
376
+ instance sends (including `identify`) is pinned to that one frozen id, and the instance stops
377
+ following the live per-open session the kit registers from `app.session_started` (see
378
+ [Session mapping](#session-mapping-know-when-a-user-opens-the-app-again)). Lifecycle analytics then
379
+ collapse onto a single device-scoped id: one "first open" for the life of the install, however many
380
+ times the user comes back. The kit warns about it in dev builds.
381
+
382
+ ```tsx
383
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId }); // ✅ follows each app-open
384
+ const analytics = createAnalytics({ serverUrl, apiKey, sessionId: myId }); // ⚠️ frozen for good
385
+ ```
386
+
387
+ Omit it. Pass one only if your host runs its own session lifecycle and owns the id the server should
388
+ correlate on. This is a different field from the per-open `sessionId` that `useWireActivation` and
389
+ `reportAppEvent` READ (that one is the live id, and reading it is always fine).
390
+
373
391
  ## Session mapping (know when a user opens the app again)
374
392
 
375
393
  Onboarding runs once, on the first launch. Session mapping is the other half: every time the
@@ -965,6 +983,61 @@ If you are outside React (a service, a saga), `createWireActivation(config)` is
965
983
  factory. It returns `{ track, sessionId, subscribeRevalidation, getRevalidationVersion }`: the
966
984
  same `track`, plus the raw pub/sub the hook wraps for you.
967
985
 
986
+ ## RevenueCat (the purchase funnel)
987
+
988
+ If you sell subscriptions with [RevenueCat](https://www.revenuecat.com/), the paywall is the other end of the funnel onboarding starts. `createRevenueCatBridge` wires the two together: one constructor, then your existing paywall call sites.
989
+
990
+ The kit does **not** depend on `react-native-purchases`, and it never will. That package is a native module, and the kit's whole promise is that it never puts a native rebuild in your way. So the adapter types the RevenueCat objects structurally instead, which means you hand it the real `CustomerInfo` / `PurchasesPackage` / `PurchasesOffering` you already have. Nothing new gets installed.
991
+
992
+ ```tsx
993
+ import { createAnalytics, createRevenueCatBridge } from "@wireai/activation";
994
+
995
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
996
+ const revenuecat = createRevenueCatBridge({ analytics, entitlementId: "pro" });
997
+ ```
998
+
999
+ Then five call sites, all fire-and-forget, none of which can throw at your paywall:
1000
+
1001
+ ```tsx
1002
+ revenuecat.paywallShown(offering, { source: variant }); // offering_id + packages_count
1003
+ revenuecat.checkoutStarted(pkg, { source: variant }); // package, product, price, currency
1004
+
1005
+ const { customerInfo } = await Purchases.purchasePackage(pkg);
1006
+ if (revenuecat.purchaseCompleted(customerInfo, pkg, { source: variant })) navigateOn();
1007
+
1008
+ revenuecat.purchaseFailed(error, pkg, { source: variant }); // reason: "cancelled" | "error"
1009
+ revenuecat.purchasesRestored(await Purchases.restorePurchases());
1010
+ revenuecat.syncPlanTier(await Purchases.getCustomerInfo()); // at launch, no event
1011
+ ```
1012
+
1013
+ **The names are canonical**, the same way `WIRE_ONBOARDING_EVENTS` standardizes the onboarding funnel: `wire_paywall_shown`, `wire_checkout_started`, `wire_purchase_completed`, `wire_purchase_failed`, `wire_purchase_restored`. They are `app_event` `question_key` values on the wire, so they are also the exact strings a review or questionnaire firing trigger matches on.
1014
+
1015
+ **What the bridge decides for you.** The entitlement check lives in one place instead of being copy-pasted at the purchase site and the restore site. `plan_tier` (`paid` / `trial` / `free`) is written to the bound user context on every entitlement change, so the whole funnel can be sliced paid versus free. The store's error `message` is dropped and only the stable RevenueCat `code` rides the event, because that message is localized, unbounded, and occasionally names the account it failed for. And a purchase the store confirmed that granted **no** entitlement fires `wire_purchase_failed` with `reason: "not_entitled"` rather than returning quietly, which is the usual shape of a broken product-to-entitlement mapping.
1016
+
1017
+ ### The join key: `device_key`, never `session_id`
1018
+
1019
+ A purchase event is worth nothing unless you can join it to the same user's onboarding. Get this wrong and you do not get a wrong number, you get a permanent zero, which is far harder to notice.
1020
+
1021
+ `session_id` is not that key. The onboarding session id is the A2A `contextId`, the app-event session id gets minted per app-open, and the kit keeps those two id spaces apart on purpose. Intersect them and you get no rows. Ever.
1022
+
1023
+ The key is **`user_context.device_key`**, and you have to put it on both sides:
1024
+
1025
+ ```tsx
1026
+ import { activationJoinContext } from "@wireai/activation";
1027
+
1028
+ // Purchase side: createAnalytics / createWireActivation auto-mint and persist a device_key
1029
+ // and stamp it on every event. Supply your own to override it.
1030
+ const analytics = createAnalytics({ serverUrl, apiKey, storage, appId, userContext: { deviceKey } });
1031
+
1032
+ // Onboarding side: forward the SAME key. The kit passes userContext verbatim into the A2A
1033
+ // session-start metadata, and the server records it on the session's session_started event.
1034
+ <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} onComplete={persist} />
1035
+ ```
1036
+
1037
+ `activationJoinContext(deviceKey)` exists because the wire key is `device_key` and the prop-facing name is `deviceKey`. Hand-writing `userContext={{ deviceKey }}` produces a bucket the server's device lookup does not read, and you get the silent-zero funnel instead of an error. This is the one place that spelling is decided.
1038
+
1039
+ Skip the onboarding half and the purchase events are still valid on their own, they just cannot be attributed back to an onboarding.
1040
+
968
1041
  ## Feature controls (per-module kill switches)
969
1042
 
970
1043
  Every activation surface has a switch you can flip from the dashboard "in case of something":
@@ -1,6 +1,6 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-BGW9uXZJ.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-C0_odnIW.mjs';
3
- export { A as AnalyticsEvent, C as ClearUserContextOptions, a as ClientEvent, b as ClientEventTarget, c as ClientEventType, d as ContextEnvelope, e as ContextEnvelopeInput, f as EnvelopeSource, g as EventQueue, h as WIRE_ONBOARDING_EVENTS, i as WireOnboardingEventName, j as analyticsUserIdStorageKey, k as buildContextEnvelope, l as clearPiiFromContext, m as clearUserContext, n as createEventQueue, o as getCurrentSessionId, p as looksLikeEmail, q as makeSessionId, r as reportClientEvent, s as reportClientEventAwait, t as reportClientEvents, u as reportClientEventsAwait, v as resetCurrentSessionId, w as setCurrentSessionId, x as toAnalyticsEvent } from '../currentSession-C0_odnIW.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DsSDHqor.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-DsSDHqor.mjs';
4
4
  import '../types-CNUqMK0D.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-BcmagF6K.mjs';
@@ -33,7 +33,12 @@ interface ScreenTrackerOptions {
33
33
  serverUrl: string;
34
34
  apiKey: string;
35
35
  };
36
- /** The onboarding/session id to correlate screen views with, when known. */
36
+ /**
37
+ * The onboarding/session id to correlate screen views with, when known. Omitting it no longer
38
+ * means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
39
+ * behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
40
+ * never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
41
+ */
37
42
  sessionId?: string;
38
43
  /** A stable, non-PII device id — groups a device's sessions server-side. */
39
44
  deviceKey?: string;
@@ -103,8 +108,14 @@ type CreateAnalyticsConfig = {
103
108
  /** Tenant API key; sent as `Authorization: Bearer`. */
104
109
  apiKey: string;
105
110
  /**
106
- * Correlation id shared by every event from this instance (and the `identify` event). Defaults
107
- * to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
111
+ * ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
112
+ * this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
113
+ * following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
114
+ * analytics then collapse onto a single device-scoped session — one "first open", forever.
115
+ *
116
+ * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
117
+ * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
118
+ * your host runs its own session lifecycle and owns the id the server should correlate on.
108
119
  */
109
120
  sessionId?: string;
110
121
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -1,6 +1,6 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-jUJd5kxu.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DdnUq2HQ.js';
3
- export { A as AnalyticsEvent, C as ClearUserContextOptions, a as ClientEvent, b as ClientEventTarget, c as ClientEventType, d as ContextEnvelope, e as ContextEnvelopeInput, f as EnvelopeSource, g as EventQueue, h as WIRE_ONBOARDING_EVENTS, i as WireOnboardingEventName, j as analyticsUserIdStorageKey, k as buildContextEnvelope, l as clearPiiFromContext, m as clearUserContext, n as createEventQueue, o as getCurrentSessionId, p as looksLikeEmail, q as makeSessionId, r as reportClientEvent, s as reportClientEventAwait, t as reportClientEvents, u as reportClientEventsAwait, v as resetCurrentSessionId, w as setCurrentSessionId, x as toAnalyticsEvent } from '../currentSession-DdnUq2HQ.js';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D6RiVtc8.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-D6RiVtc8.js';
4
4
  import '../types-Buj9Lw9t.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-BcmagF6K.js';
@@ -33,7 +33,12 @@ interface ScreenTrackerOptions {
33
33
  serverUrl: string;
34
34
  apiKey: string;
35
35
  };
36
- /** The onboarding/session id to correlate screen views with, when known. */
36
+ /**
37
+ * The onboarding/session id to correlate screen views with, when known. Omitting it no longer
38
+ * means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
39
+ * behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
40
+ * never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
41
+ */
37
42
  sessionId?: string;
38
43
  /** A stable, non-PII device id — groups a device's sessions server-side. */
39
44
  deviceKey?: string;
@@ -103,8 +108,14 @@ type CreateAnalyticsConfig = {
103
108
  /** Tenant API key; sent as `Authorization: Bearer`. */
104
109
  apiKey: string;
105
110
  /**
106
- * Correlation id shared by every event from this instance (and the `identify` event). Defaults
107
- * to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
111
+ * ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
112
+ * this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
113
+ * following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
114
+ * analytics then collapse onto a single device-scoped session — one "first open", forever.
115
+ *
116
+ * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
117
+ * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
118
+ * your host runs its own session lifecycle and owns the id the server should correlate on.
108
119
  */
109
120
  sessionId?: string;
110
121
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -413,6 +413,23 @@ var buildEventsRequest = (target, events) => {
413
413
  return null;
414
414
  }
415
415
  };
416
+ var warnOnSkippedEvents = (res) => {
417
+ try {
418
+ const json = res == null ? void 0 : res.json;
419
+ if (typeof json !== "function") return;
420
+ void Promise.resolve(json.call(res)).then((body) => {
421
+ const skipped = body == null ? void 0 : body.skipped;
422
+ if (typeof skipped !== "number" || skipped <= 0) return;
423
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
424
+ console.warn(
425
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) (skipped in the response body) \u2014 they are gone, not retried. The usual cause is an event with a missing or empty session_id.`
426
+ );
427
+ }
428
+ }).catch(() => {
429
+ });
430
+ } catch {
431
+ }
432
+ };
416
433
  var reportClientEvents = (target, events) => {
417
434
  try {
418
435
  const req = buildEventsRequest(target, events);
@@ -435,15 +452,54 @@ var reportClientEventsAwait = async (target, events) => {
435
452
  };
436
453
  var reportClientEventAwait = (target, event) => reportClientEventsAwait(target, [event]);
437
454
 
455
+ // src/analytics/currentSession.ts
456
+ var CURRENT_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
457
+ "@wireai/activation:currentSessionId"
458
+ );
459
+ var globalSlot = globalThis;
460
+ var setCurrentSessionId = (id) => {
461
+ if (typeof id === "string" && id.length > 0) {
462
+ globalSlot[CURRENT_SESSION_ID_SLOT] = id;
463
+ }
464
+ };
465
+ var getCurrentSessionId = () => globalSlot[CURRENT_SESSION_ID_SLOT];
466
+ var resetCurrentSessionId = () => {
467
+ globalSlot[CURRENT_SESSION_ID_SLOT] = void 0;
468
+ };
469
+ var warnInDev = (message) => {
470
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
471
+ console.warn(message);
472
+ return true;
473
+ }
474
+ return false;
475
+ };
476
+ var MINT_WARNING = "[wireai] No app-open session was registered, so a session id was minted for this event (the server drops an event that has no session_id, and still answers 200). Mount useLifecycleEvents at your app root so events correlate to a real app-open.";
477
+ var MINT_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
478
+ "@wireai/activation:currentSessionIdMintWarned"
479
+ );
480
+ var warnSlot = globalThis;
481
+ var ensureCurrentSessionId = () => {
482
+ const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
483
+ if (typeof existing === "string" && existing.length > 0) return existing;
484
+ const minted = makeSessionId();
485
+ globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
486
+ if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
487
+ warnSlot[MINT_WARNED_SLOT] = true;
488
+ }
489
+ return minted;
490
+ };
491
+
438
492
  // src/reviews/transport.ts
439
493
  var reportAppEvent = (target, name2, options = {}) => {
494
+ var _a2;
440
495
  if (!(target == null ? void 0 : target.serverUrl) || !name2) return;
441
496
  try {
497
+ const supplied = (_a2 = options.sessionId) != null ? _a2 : "";
442
498
  const event = {
443
499
  event_type: "app_event",
444
- question_key: name2
500
+ question_key: name2,
501
+ session_id: supplied.trim().length > 0 ? supplied : ensureCurrentSessionId()
445
502
  };
446
- if (options.sessionId) event.session_id = options.sessionId;
447
503
  if (options.deviceKey) event.user_context = { device_key: options.deviceKey };
448
504
  if (options.meta && Object.keys(options.meta).length > 0) {
449
505
  event.meta = JSON.stringify(options.meta);
@@ -879,6 +935,7 @@ var createEventQueue = (options) => {
879
935
  const timer = setTimeout(() => controller == null ? void 0 : controller.abort(), 15e3);
880
936
  try {
881
937
  const res = await fetch(req.url, { ...req.init, signal: controller == null ? void 0 : controller.signal });
938
+ warnOnSkippedEvents(res);
882
939
  return !!(res && res.ok);
883
940
  } catch {
884
941
  return false;
@@ -957,21 +1014,6 @@ var createEventQueue = (options) => {
957
1014
  return { enqueue, flush, notifyOnline, size };
958
1015
  };
959
1016
 
960
- // src/analytics/currentSession.ts
961
- var CURRENT_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
962
- "@wireai/activation:currentSessionId"
963
- );
964
- var globalSlot = globalThis;
965
- var setCurrentSessionId = (id) => {
966
- if (typeof id === "string" && id.length > 0) {
967
- globalSlot[CURRENT_SESSION_ID_SLOT] = id;
968
- }
969
- };
970
- var getCurrentSessionId = () => globalSlot[CURRENT_SESSION_ID_SLOT];
971
- var resetCurrentSessionId = () => {
972
- globalSlot[CURRENT_SESSION_ID_SLOT] = void 0;
973
- };
974
-
975
1017
  // src/identity/userIdentity.ts
976
1018
  var USER_ID_MAX_LENGTH = 128;
977
1019
  var sanitizeUserId = (raw) => {
@@ -1068,9 +1110,50 @@ var mintDeviceId = () => {
1068
1110
  const time = Date.now().toString(36);
1069
1111
  return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
1070
1112
  };
1113
+ var AUTO_DEVICE_KEY_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:autoDeviceKeys");
1114
+ var deviceKeyGlobal = globalThis;
1115
+ var autoDeviceKeyRegistry = () => {
1116
+ const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
1117
+ if (existing) return existing;
1118
+ const created = { keys: /* @__PURE__ */ new Map(), hydrating: /* @__PURE__ */ new Set() };
1119
+ deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
1120
+ return created;
1121
+ };
1122
+ var resolveAutoDeviceKey = (opts = {}) => {
1123
+ var _a2, _b;
1124
+ const registry = autoDeviceKeyRegistry();
1125
+ const appId = (_a2 = opts.appId) != null ? _a2 : "default";
1126
+ let id = registry.keys.get(appId);
1127
+ if (!id) {
1128
+ id = mintDeviceId();
1129
+ registry.keys.set(appId, id);
1130
+ }
1131
+ const storage = opts.storage;
1132
+ if (storage && !registry.hydrating.has(appId)) {
1133
+ registry.hydrating.add(appId);
1134
+ const slot = deviceIdStorageKey(appId);
1135
+ const minted = id;
1136
+ try {
1137
+ void Promise.resolve(storage.getItem(slot)).then((saved) => {
1138
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
1139
+ if (persisted) registry.keys.set(appId, persisted);
1140
+ else void Promise.resolve(storage.setItem(slot, minted)).catch(() => {
1141
+ });
1142
+ }).catch(() => {
1143
+ });
1144
+ } catch {
1145
+ }
1146
+ }
1147
+ return (_b = registry.keys.get(appId)) != null ? _b : id;
1148
+ };
1149
+ var resetAutoDeviceKeys = () => {
1150
+ const registry = autoDeviceKeyRegistry();
1151
+ registry.keys.clear();
1152
+ registry.hydrating.clear();
1153
+ };
1071
1154
 
1072
1155
  // src/analytics/analyticsFacade.ts
1073
- var warnInDev = (message) => {
1156
+ var warnInDev2 = (message) => {
1074
1157
  if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
1075
1158
  console.warn(message);
1076
1159
  }
@@ -1082,20 +1165,20 @@ var createAnalytics = (config, options = {}) => {
1082
1165
  var _a3, _b2;
1083
1166
  return (_b2 = (_a3 = config.sessionId) != null ? _a3 : getCurrentSessionId()) != null ? _b2 : instanceSessionId;
1084
1167
  };
1168
+ if (config.sessionId) {
1169
+ warnInDev2(
1170
+ "[wireai] createAnalytics({ sessionId }) PINS every event from this instance to that one frozen id and opts out of the live per-open session (app.session_started) \u2014 lifecycle analytics collapse onto a single device-scoped id. Remove it unless your host runs its own session lifecycle."
1171
+ );
1172
+ }
1173
+ const detectedAppVersion = detectAppVersion();
1085
1174
  let userContext = { ...(_b = config.userContext) != null ? _b : {} };
1086
1175
  const hostDeviceKeyAtInit = typeof ((_c = config.userContext) == null ? void 0 : _c.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1087
- let autoDeviceKey = mintDeviceId();
1088
- if (config.storage && !hostDeviceKeyAtInit) {
1089
- const storage = config.storage;
1090
- const deviceKey = deviceIdStorageKey(config.appId);
1091
- void storage.getItem(deviceKey).then((saved) => {
1092
- const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
1093
- if (persisted) autoDeviceKey = persisted;
1094
- else void storage.setItem(deviceKey, autoDeviceKey).catch(() => {
1095
- });
1096
- }).catch(() => {
1097
- });
1098
- }
1176
+ const autoDeviceKeyOptions = {
1177
+ appId: config.appId,
1178
+ // A host-supplied deviceKey opts out of minting AND persisting (unchanged contract).
1179
+ storage: hostDeviceKeyAtInit ? void 0 : config.storage
1180
+ };
1181
+ if (!hostDeviceKeyAtInit) resolveAutoDeviceKey(autoDeviceKeyOptions);
1099
1182
  const envelope = () => {
1100
1183
  var _a3;
1101
1184
  return buildContextEnvelope({
@@ -1121,20 +1204,21 @@ var createAnalytics = (config, options = {}) => {
1121
1204
  });
1122
1205
  }
1123
1206
  const applyContext = (event) => {
1124
- var _a3;
1207
+ var _a3, _b2;
1125
1208
  const hostDeviceKey = typeof userContext.deviceKey === "string" && userContext.deviceKey.trim() ? userContext.deviceKey : void 0;
1126
1209
  const resolved = resolveUserContext(
1127
- { ...userContext, deviceKey: hostDeviceKey != null ? hostDeviceKey : autoDeviceKey },
1128
- { autoAppVersion: config.appVersion }
1210
+ // `??` is lazy on purpose: a host-supplied key must never even touch the auto registry.
1211
+ { ...userContext, deviceKey: hostDeviceKey != null ? hostDeviceKey : resolveAutoDeviceKey(autoDeviceKeyOptions) },
1212
+ { autoAppVersion: (_a3 = config.appVersion) != null ? _a3 : detectedAppVersion }
1129
1213
  );
1130
1214
  if (resolved.userContext) {
1131
- event.user_context = { ...resolved.userContext, ...(_a3 = event.user_context) != null ? _a3 : {} };
1215
+ event.user_context = { ...resolved.userContext, ...(_b2 = event.user_context) != null ? _b2 : {} };
1132
1216
  }
1133
1217
  if (boundUserId && !event.user_id) event.user_id = boundUserId;
1134
1218
  };
1135
1219
  const guardUserId = (clean) => {
1136
1220
  if (config.allowEmailAsUserId || !looksLikeEmail(clean)) return clean;
1137
- warnInDev(
1221
+ warnInDev2(
1138
1222
  "[wireai] identify() was called with an email-shaped id. A raw email must NOT be the opaque user_id (PII leak) \u2014 pass it as userContext.userEmail instead. Binding was skipped. Set allowEmailAsUserId:true on createAnalytics if your user id genuinely is an email."
1139
1223
  );
1140
1224
  return void 0;
@@ -1217,6 +1301,7 @@ var createAnalytics = (config, options = {}) => {
1217
1301
  };
1218
1302
  };
1219
1303
  var useAnalytics = (config, options = {}) => {
1304
+ var _a2;
1220
1305
  const ref = react.useRef(void 0);
1221
1306
  const prevKeys = react.useRef("");
1222
1307
  const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
@@ -1224,9 +1309,15 @@ var useAnalytics = (config, options = {}) => {
1224
1309
  prevKeys.current = currentKeys;
1225
1310
  ref.current = createAnalytics(config, options);
1226
1311
  }
1312
+ const hostDeviceKey = typeof ((_a2 = config.userContext) == null ? void 0 : _a2.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1313
+ react.useEffect(() => {
1314
+ var _a3;
1315
+ if (hostDeviceKey) (_a3 = ref.current) == null ? void 0 : _a3.setUserContext({ deviceKey: hostDeviceKey });
1316
+ }, [hostDeviceKey]);
1227
1317
  return ref.current;
1228
1318
  };
1229
1319
 
1320
+ exports.AUTO_DEVICE_ID_PREFIX = AUTO_DEVICE_ID_PREFIX;
1230
1321
  exports.WIRE_ONBOARDING_EVENTS = WIRE_ONBOARDING_EVENTS;
1231
1322
  exports.analyticsUserIdStorageKey = analyticsUserIdStorageKey;
1232
1323
  exports.buildContextEnvelope = buildContextEnvelope;
@@ -1235,6 +1326,8 @@ exports.clearUserContext = clearUserContext;
1235
1326
  exports.createAnalytics = createAnalytics;
1236
1327
  exports.createEventQueue = createEventQueue;
1237
1328
  exports.createScreenTracker = createScreenTracker;
1329
+ exports.deviceIdStorageKey = deviceIdStorageKey;
1330
+ exports.ensureCurrentSessionId = ensureCurrentSessionId;
1238
1331
  exports.getActiveRouteName = getActiveRouteName;
1239
1332
  exports.getCurrentSessionId = getCurrentSessionId;
1240
1333
  exports.looksLikeEmail = looksLikeEmail;
@@ -1244,7 +1337,9 @@ exports.reportClientEvent = reportClientEvent;
1244
1337
  exports.reportClientEventAwait = reportClientEventAwait;
1245
1338
  exports.reportClientEvents = reportClientEvents;
1246
1339
  exports.reportClientEventsAwait = reportClientEventsAwait;
1340
+ exports.resetAutoDeviceKeys = resetAutoDeviceKeys;
1247
1341
  exports.resetCurrentSessionId = resetCurrentSessionId;
1342
+ exports.resolveAutoDeviceKey = resolveAutoDeviceKey;
1248
1343
  exports.screenTrackingHandler = screenTrackingHandler;
1249
1344
  exports.setCurrentSessionId = setCurrentSessionId;
1250
1345
  exports.toAnalyticsEvent = toAnalyticsEvent;