@wireai/activation 0.10.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 +111 -1
- package/INTEGRATION_PROMPT.md +13 -1
- package/README.md +106 -4
- package/dist/analytics/index.d.mts +35 -6
- package/dist/analytics/index.d.ts +35 -6
- package/dist/analytics/index.js +222 -94
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +214 -95
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-D6RiVtc8.d.ts} +187 -29
- package/dist/{currentSession-DdDkprpM.d.mts → currentSession-DsSDHqor.d.mts} +187 -29
- package/dist/index.d.mts +236 -82
- package/dist/index.d.ts +236 -82
- package/dist/index.js +330 -60
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +314 -61
- 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 +59 -8
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +59 -8
- 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 +97 -17
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +97 -17
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/{transport-Bzb-bcB2.d.mts → transport-CF_eHwzC.d.mts} +15 -16
- package/dist/{transport-B31G0Cib.d.ts → transport-DsRe4epC.d.ts} +15 -16
- package/llms.txt +9 -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 +113 -29
- package/src/analytics/currentSession.ts +83 -0
- package/src/analytics/eventQueue.ts +20 -11
- package/src/analytics/index.ts +29 -1
- package/src/analytics/reportClientEvent.ts +50 -2
- 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 +73 -0
- package/src/identity/userIdentity.ts +10 -0
- package/src/index.ts +50 -2
- package/src/questionnaire/runtime.ts +12 -2
- package/src/questionnaire/transport.ts +5 -1
- 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/decision.ts +8 -1
- package/src/reviews/runtime.ts +92 -1
- package/src/reviews/transport.ts +27 -10
- package/src/reviews/useReviewGate.ts +12 -7
- package/src/session-analytics/lifecycle.ts +15 -13
- package/src/session-analytics/reportSessionStart.ts +15 -4
- package/src/session-analytics/useLifecycleEvents.ts +68 -28
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,7 +3,117 @@
|
|
|
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
|
-
## [
|
|
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
|
|
43
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
### Changed (BEHAVIOR) — review gate no longer fires on the first session
|
|
67
|
+
|
|
68
|
+
- **`ReviewConfig.minSessions` now defaults to `2` (was `0`).** An unconfigured host with no server
|
|
69
|
+
decision used to fire the review prompt on the FIRST mount (`resolveRules` defaulted the floor to
|
|
70
|
+
0, so `local_rules_met` was immediate). That is the shape of the 2026-07-16 incident where a
|
|
71
|
+
first-session user got prompted and left 1 star. The local floor is now 2, so the client rules
|
|
72
|
+
need a second session before they can fire. A server `decision` (from `fetchReviewDecision`) still
|
|
73
|
+
overrides everything, and a host that genuinely wants first-session prompting sets
|
|
74
|
+
`minSessions: 1` (or `0`) explicitly.
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- **Logout / reset.** `analytics.reset()` on the `createAnalytics` surface unbinds the user
|
|
79
|
+
(clears the in-memory `boundUserId`, strips `userId` / `userEmail` / `extra` from the bound
|
|
80
|
+
`WireUserContext`, keeps the non-PII `device_key`, and removes the persisted
|
|
81
|
+
`wireai:analytics:userId:<appId>` key). New top-level **`clearUserContext({ storage, appId })`**
|
|
82
|
+
covers the `createWireActivation` / `wire` path (recreate the instance without the user's context
|
|
83
|
+
after calling it). Fixes shared-device attribution of user B's events to user A. Also exported:
|
|
84
|
+
`clearPiiFromContext`, `analyticsUserIdStorageKey`.
|
|
85
|
+
- **Email-shape guard on `identify`.** `identify(id)` and `setUserContext({ userId })` now refuse to
|
|
86
|
+
bind an email-shaped id (a raw email in the opaque `user_id` is a PII leak, it belongs in
|
|
87
|
+
`userContext.userEmail`) and warn in dev. Opt out with `allowEmailAsUserId: true` on
|
|
88
|
+
`createAnalytics`. New exported helper `looksLikeEmail`. Non-breaking.
|
|
89
|
+
- **Client event timestamp.** `ClientEvent` gains an optional additive `ts`. The offline queue stamps
|
|
90
|
+
it at enqueue time so two byte-identical events fired seconds apart (a genuine repeat) are no
|
|
91
|
+
longer collapsed by the identical-JSON de-dup, while two same-instant re-enqueues still collapse.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **`first_open` phantom session.** `useLifecycleEvents` now mints ONE per-open `session_id` on mount
|
|
96
|
+
and hands it to BOTH `app.first_open` and `app.session_started`, so `first_open` no longer carries
|
|
97
|
+
a session id the server never saw a `session_started` for (which the server back-filled into a
|
|
98
|
+
phantom session, inflating session counts). A later foreground open still mints its own fresh id.
|
|
99
|
+
- **Questionnaire decision guard.** `fetchQuestionnaireDecision` now returns `null` for a body
|
|
100
|
+
without a boolean `fire` (mirrors `fetchReviewDecision`), instead of letting `{}` through as a
|
|
101
|
+
truthy "decision" whose `fire === undefined` reads as a silent "never fire".
|
|
102
|
+
|
|
103
|
+
### Internal
|
|
104
|
+
|
|
105
|
+
- **Unified the `/v1/events` POST builders.** `buildEventsRequest` (in `reportClientEvent.ts`) is now
|
|
106
|
+
exported and is the ONE description of the events endpoint (url + headers + body). The offline
|
|
107
|
+
queue (`postBatch`, keeping its abort-timeout), `reportAppEvent` (reviews transport), and the
|
|
108
|
+
lifecycle direct-POST fallback all route through it instead of hand-rolling the request.
|
|
109
|
+
- **Docs truth pass.** New README "Rich user context & PII" section (raw `userEmail` by default +
|
|
110
|
+
`hashEmail`, `reset` / `clearUserContext` on logout, the auto per-install `device_key` and its
|
|
111
|
+
store-declaration implications). The blanket "No PII / nothing identifies a device" claims are
|
|
112
|
+
scoped to the onboarding `userContext` prop + the `collectDeviceContext()` snapshot. `llms.txt`
|
|
113
|
+
now lists the `/analytics`, `/reviews`, `/questionnaire` subpath surfaces.
|
|
114
|
+
- Added the `session/sessionIdentity.integration.test.ts` dist-canary (PR #47/#48).
|
|
115
|
+
|
|
116
|
+
## [0.10.0] - 2026-07-20
|
|
7
117
|
|
|
8
118
|
### Added: `wire.track` + `useWireActivation` (fire a gate off an in-app action)
|
|
9
119
|
|
package/INTEGRATION_PROMPT.md
CHANGED
|
@@ -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.
|
|
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
|
@@ -246,7 +246,7 @@ import { WireOnboarding } from "@wireai/activation";
|
|
|
246
246
|
| `storage` | `WireOnboardingStorage` | Host-injected storage (AsyncStorage-compatible `getItem/setItem/removeItem`) for **session-id persistence**: an app KILL mid-onboarding resumes the SAME backend session instead of minting a new one, so the funnel's `started` count stays honest (no phantom drops). Pass AsyncStorage as-is, or a 3-line MMKV wrapper. Omit for the previous per-mount behavior. Persists the kit's own correlation seed only — answers stay the host's job via `onComplete`. |
|
|
247
247
|
| `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
|
|
248
248
|
| `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
|
|
249
|
-
| `userContext` | `Record<string, string \| number \| boolean>` | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. **No PII** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). |
|
|
249
|
+
| `userContext` | `Record<string, string \| number \| boolean>` | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
|
|
250
250
|
| `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
|
|
251
251
|
|
|
252
252
|
## Helpers (the reusable substrate)
|
|
@@ -274,7 +274,7 @@ You rarely hand-roll config, gating, analytics, or attribution — the kit ships
|
|
|
274
274
|
Two optional, host-injected inputs let onboarding analytics segment the funnel by device and by what your app already knows about the user. The kit collects the device snapshot itself (no dependency added); everything else you pass.
|
|
275
275
|
|
|
276
276
|
- **`config.appVersion`** (string): your app version, e.g. `Constants.expoConfig?.version`. The kit reads nothing to get it, so it stays dependency-free; you inject it.
|
|
277
|
-
- **`userContext`** (`Record<string, string | number | boolean>`): non-PII context the app already has, like signup method, referral source, plan tier, or a hashed user id. Same host-injection idea as `storage`. Do not put raw emails, names, or phone numbers here; pass a hash if you need a user key. Values are primitives only, and the server caps key count/size and drops deep nesting.
|
|
277
|
+
- **`userContext`** (`Record<string, string | number | boolean>`): non-PII context the app already has, like signup method, referral source, plan tier, or a hashed user id. Same host-injection idea as `storage`. Do not put raw emails, names, or phone numbers here; pass a hash if you need a user key. Values are primitives only, and the server caps key count/size and drops deep nesting. This is the **onboarding** prop specifically. The analytics/activation surface takes a richer object with an opt-in raw email field; see [Rich user context & PII](#rich-user-context--pii).
|
|
278
278
|
|
|
279
279
|
Both ride the A2A session-start metadata AND every client event (`dropped`, `client_fallback`). Old servers ignore the extra fields, so it is backward compatible.
|
|
280
280
|
|
|
@@ -296,7 +296,9 @@ Only React Native built-ins and the standard `Intl` global, each read defensivel
|
|
|
296
296
|
|
|
297
297
|
### No tracking (privacy-label-neutral)
|
|
298
298
|
|
|
299
|
-
No advertising IDs (no IDFA/GAID), no `getUniqueId`, no fingerprinting APIs.
|
|
299
|
+
No advertising IDs (no IDFA/GAID), no `getUniqueId`, no fingerprinting APIs. **The `collectDeviceContext()` snapshot described in this section** carries no unique identifier, so adopting it alone does not change your App Privacy or Data Safety declarations, and it adds zero dependencies. On the backend the server derives a coarse country from the sent locale/timezone only, and never processes or stores IP addresses.
|
|
300
|
+
|
|
301
|
+
> ⚠️ **The analytics façade is different.** `createAnalytics` / `useWireActivation` auto-mint a stable, persisted, per-install `device_key` (a first-party correlation id, the privacy category of a first-party cookie) that rides on every event. It is not a hardware id and cannot be joined across apps, but it IS a per-install identifier, so if you adopt that surface, declare it accordingly. See [Rich user context & PII](#rich-user-context--pii).
|
|
300
302
|
|
|
301
303
|
## User identity
|
|
302
304
|
|
|
@@ -339,7 +341,52 @@ await identifyOnboarding({ config, userId: newUser.id, contextId: contextIdRef.c
|
|
|
339
341
|
|
|
340
342
|
If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws; it resolves `true` when it dispatched an `identify` and `false` when it could not (no user id, no server url, or no resolvable session).
|
|
341
343
|
|
|
342
|
-
**No PII.** `userId` is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as `userContext
|
|
344
|
+
**No PII.** The onboarding `userId` prop is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as the onboarding `userContext` prop.
|
|
345
|
+
|
|
346
|
+
## Rich user context & PII
|
|
347
|
+
|
|
348
|
+
The onboarding props above are non-PII by contract. The **analytics and activation surfaces** (`createAnalytics(...)` / `useAnalytics`, `createWireActivation(...)` / `useWireActivation`) take a richer `WireUserContext` object that CAN carry PII, so it needs its own privacy rules. Three things to know:
|
|
349
|
+
|
|
350
|
+
**1. `userEmail` sends a RAW email by default.** `WireUserContext.userEmail` lands in its own `user_context.user_email` field (never mixed into the opaque `userId`). It is opt-in: the kit never auto-collects it, and you pass it only with the user's consent (for EU users, treat it as personal data). By default it is sent **as-is**. For a non-reversible form, set `hashEmail: true` and the kit folds it with a dependency-free hash and stamps `user_context.user_email_hashed: true`. For a cryptographic digest, hash it host-side (e.g. SHA-256 via `expo-crypto`) and pass the digest as `userEmail` with `hashEmail` falsy.
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
const analytics = createAnalytics({ serverUrl, apiKey, storage, appId });
|
|
354
|
+
analytics.setUserContext({ userId: user.id, userEmail: user.email, hashEmail: true }); // opt-in, folded
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**Email-shape guard.** `identify(id)` (and `setUserContext({ userId })`) refuse to bind an id that looks like an email and warn in dev, because a raw email in the opaque `user_id` is a PII leak, it belongs in `userEmail`. If your internal user id genuinely IS an email, set `allowEmailAsUserId: true` on `createAnalytics` to opt out of the guard.
|
|
358
|
+
|
|
359
|
+
**2. Clear the user on logout.** On a shared device, a persisted user binding would attribute user B's events to user A. Two ways to clear it:
|
|
360
|
+
|
|
361
|
+
- Hold the analytics instance: call **`analytics.reset()`**. It unbinds the in-memory user, strips the PII fields (`userId`, `userEmail`, `extra`) from the bound context, and removes the persisted `wireai:analytics:userId:<appId>` key. The non-PII `device_key` is kept (it groups a device, not a user).
|
|
362
|
+
- The `wire` / `createWireActivation` path captures its config immutably, so call the standalone **`clearUserContext({ storage, appId })`** to purge the persisted binding, and RECREATE the instance without the user's `userContext` so no further events carry their identity.
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
import { clearUserContext } from "@wireai/activation";
|
|
366
|
+
// on sign-out:
|
|
367
|
+
analytics.reset(); // if you hold the analytics instance
|
|
368
|
+
await clearUserContext({ storage, appId }); // the wire/activation path (then recreate without PII)
|
|
369
|
+
```
|
|
370
|
+
|
|
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
|
+
|
|
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).
|
|
343
390
|
|
|
344
391
|
## Session mapping (know when a user opens the app again)
|
|
345
392
|
|
|
@@ -936,6 +983,61 @@ If you are outside React (a service, a saga), `createWireActivation(config)` is
|
|
|
936
983
|
factory. It returns `{ track, sessionId, subscribeRevalidation, getRevalidationVersion }`: the
|
|
937
984
|
same `track`, plus the raw pub/sub the hook wraps for you.
|
|
938
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
|
+
|
|
939
1041
|
## Feature controls (per-module kill switches)
|
|
940
1042
|
|
|
941
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-
|
|
2
|
-
import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-
|
|
3
|
-
export { A as AnalyticsEvent, C as ClientEvent,
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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>`). */
|
|
@@ -131,6 +142,14 @@ type CreateAnalyticsConfig = {
|
|
|
131
142
|
* review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
|
|
132
143
|
*/
|
|
133
144
|
userContext?: WireUserContext;
|
|
145
|
+
/**
|
|
146
|
+
* ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
|
|
147
|
+
* REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
|
|
148
|
+
* raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
|
|
149
|
+
* `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
|
|
150
|
+
* email address and you accept it as the pseudonymous key. Default `false` (guard on).
|
|
151
|
+
*/
|
|
152
|
+
allowEmailAsUserId?: boolean;
|
|
134
153
|
};
|
|
135
154
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
136
155
|
type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
|
|
@@ -152,6 +171,16 @@ type Analytics = {
|
|
|
152
171
|
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
153
172
|
*/
|
|
154
173
|
setUserContext(partial: Partial<WireUserContext>): void;
|
|
174
|
+
/**
|
|
175
|
+
* LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
|
|
176
|
+
* Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
|
|
177
|
+
* `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
|
|
178
|
+
* which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
|
|
179
|
+
* key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
|
|
180
|
+
* next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
|
|
181
|
+
* screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
|
|
182
|
+
*/
|
|
183
|
+
reset(): void;
|
|
155
184
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
156
185
|
flush(): void;
|
|
157
186
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-
|
|
2
|
-
import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-
|
|
3
|
-
export { A as AnalyticsEvent, C as ClientEvent,
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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>`). */
|
|
@@ -131,6 +142,14 @@ type CreateAnalyticsConfig = {
|
|
|
131
142
|
* review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
|
|
132
143
|
*/
|
|
133
144
|
userContext?: WireUserContext;
|
|
145
|
+
/**
|
|
146
|
+
* ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
|
|
147
|
+
* REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
|
|
148
|
+
* raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
|
|
149
|
+
* `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
|
|
150
|
+
* email address and you accept it as the pseudonymous key. Default `false` (guard on).
|
|
151
|
+
*/
|
|
152
|
+
allowEmailAsUserId?: boolean;
|
|
134
153
|
};
|
|
135
154
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
136
155
|
type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
|
|
@@ -152,6 +171,16 @@ type Analytics = {
|
|
|
152
171
|
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
153
172
|
*/
|
|
154
173
|
setUserContext(partial: Partial<WireUserContext>): void;
|
|
174
|
+
/**
|
|
175
|
+
* LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
|
|
176
|
+
* Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
|
|
177
|
+
* `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
|
|
178
|
+
* which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
|
|
179
|
+
* key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
|
|
180
|
+
* next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
|
|
181
|
+
* screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
|
|
182
|
+
*/
|
|
183
|
+
reset(): void;
|
|
155
184
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
156
185
|
flush(): void;
|
|
157
186
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|