@wireai/activation 0.10.0 → 0.11.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/CHANGELOG.md +55 -0
- package/README.md +33 -4
- package/dist/analytics/index.d.mts +21 -3
- package/dist/analytics/index.d.ts +21 -3
- package/dist/analytics/index.js +94 -61
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +91 -62
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-DdDkprpM.d.mts → currentSession-C0_odnIW.d.mts} +101 -1
- package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-DdnUq2HQ.d.ts} +101 -1
- package/dist/index.d.mts +3 -49
- package/dist/index.d.ts +3 -49
- package/dist/index.js +50 -41
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +47 -42
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +10 -2
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +10 -2
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +1 -1
- package/dist/reviews/index.d.ts +1 -1
- package/dist/reviews/index.js +24 -9
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +24 -9
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/{transport-Bzb-bcB2.d.mts → transport-BGW9uXZJ.d.mts} +0 -15
- package/dist/{transport-B31G0Cib.d.ts → transport-jUJd5kxu.d.ts} +0 -15
- package/llms.txt +8 -0
- package/package.json +1 -1
- package/src/analytics/analyticsFacade.ts +72 -9
- package/src/analytics/eventQueue.ts +12 -11
- package/src/analytics/index.ts +9 -0
- package/src/analytics/reportClientEvent.ts +12 -2
- package/src/context/userContext.ts +55 -0
- package/src/identity/userIdentity.ts +10 -0
- package/src/index.ts +5 -1
- package/src/questionnaire/transport.ts +5 -1
- package/src/reviews/decision.ts +8 -1
- package/src/reviews/transport.ts +6 -8
- package/src/session-analytics/lifecycle.ts +6 -11
- package/src/session-analytics/useLifecycleEvents.ts +41 -27
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,61 @@ Historical entries below the rename keep the old package name on purpose.
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
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.
|
|
10
|
+
|
|
11
|
+
### Changed (BEHAVIOR) — review gate no longer fires on the first session
|
|
12
|
+
|
|
13
|
+
- **`ReviewConfig.minSessions` now defaults to `2` (was `0`).** An unconfigured host with no server
|
|
14
|
+
decision used to fire the review prompt on the FIRST mount (`resolveRules` defaulted the floor to
|
|
15
|
+
0, so `local_rules_met` was immediate). That is the shape of the 2026-07-16 incident where a
|
|
16
|
+
first-session user got prompted and left 1 star. The local floor is now 2, so the client rules
|
|
17
|
+
need a second session before they can fire. A server `decision` (from `fetchReviewDecision`) still
|
|
18
|
+
overrides everything, and a host that genuinely wants first-session prompting sets
|
|
19
|
+
`minSessions: 1` (or `0`) explicitly.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Logout / reset.** `analytics.reset()` on the `createAnalytics` surface unbinds the user
|
|
24
|
+
(clears the in-memory `boundUserId`, strips `userId` / `userEmail` / `extra` from the bound
|
|
25
|
+
`WireUserContext`, keeps the non-PII `device_key`, and removes the persisted
|
|
26
|
+
`wireai:analytics:userId:<appId>` key). New top-level **`clearUserContext({ storage, appId })`**
|
|
27
|
+
covers the `createWireActivation` / `wire` path (recreate the instance without the user's context
|
|
28
|
+
after calling it). Fixes shared-device attribution of user B's events to user A. Also exported:
|
|
29
|
+
`clearPiiFromContext`, `analyticsUserIdStorageKey`.
|
|
30
|
+
- **Email-shape guard on `identify`.** `identify(id)` and `setUserContext({ userId })` now refuse to
|
|
31
|
+
bind an email-shaped id (a raw email in the opaque `user_id` is a PII leak, it belongs in
|
|
32
|
+
`userContext.userEmail`) and warn in dev. Opt out with `allowEmailAsUserId: true` on
|
|
33
|
+
`createAnalytics`. New exported helper `looksLikeEmail`. Non-breaking.
|
|
34
|
+
- **Client event timestamp.** `ClientEvent` gains an optional additive `ts`. The offline queue stamps
|
|
35
|
+
it at enqueue time so two byte-identical events fired seconds apart (a genuine repeat) are no
|
|
36
|
+
longer collapsed by the identical-JSON de-dup, while two same-instant re-enqueues still collapse.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`first_open` phantom session.** `useLifecycleEvents` now mints ONE per-open `session_id` on mount
|
|
41
|
+
and hands it to BOTH `app.first_open` and `app.session_started`, so `first_open` no longer carries
|
|
42
|
+
a session id the server never saw a `session_started` for (which the server back-filled into a
|
|
43
|
+
phantom session, inflating session counts). A later foreground open still mints its own fresh id.
|
|
44
|
+
- **Questionnaire decision guard.** `fetchQuestionnaireDecision` now returns `null` for a body
|
|
45
|
+
without a boolean `fire` (mirrors `fetchReviewDecision`), instead of letting `{}` through as a
|
|
46
|
+
truthy "decision" whose `fire === undefined` reads as a silent "never fire".
|
|
47
|
+
|
|
48
|
+
### Internal
|
|
49
|
+
|
|
50
|
+
- **Unified the `/v1/events` POST builders.** `buildEventsRequest` (in `reportClientEvent.ts`) is now
|
|
51
|
+
exported and is the ONE description of the events endpoint (url + headers + body). The offline
|
|
52
|
+
queue (`postBatch`, keeping its abort-timeout), `reportAppEvent` (reviews transport), and the
|
|
53
|
+
lifecycle direct-POST fallback all route through it instead of hand-rolling the request.
|
|
54
|
+
- **Docs truth pass.** New README "Rich user context & PII" section (raw `userEmail` by default +
|
|
55
|
+
`hashEmail`, `reset` / `clearUserContext` on logout, the auto per-install `device_key` and its
|
|
56
|
+
store-declaration implications). The blanket "No PII / nothing identifies a device" claims are
|
|
57
|
+
scoped to the onboarding `userContext` prop + the `collectDeviceContext()` snapshot. `llms.txt`
|
|
58
|
+
now lists the `/analytics`, `/reviews`, `/questionnaire` subpath surfaces.
|
|
59
|
+
- Added the `session/sessionIdentity.integration.test.ts` dist-canary (PR #47/#48).
|
|
60
|
+
|
|
61
|
+
## [0.10.0] - 2026-07-20
|
|
62
|
+
|
|
8
63
|
### Added: `wire.track` + `useWireActivation` (fire a gate off an in-app action)
|
|
9
64
|
|
|
10
65
|
A kit-owned event-trigger surface so a consumer stops hand-rolling the session-id + await-POST +
|
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,34 @@ 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.
|
|
343
372
|
|
|
344
373
|
## Session mapping (know when a user opens the app again)
|
|
345
374
|
|
|
@@ -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
|
|
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';
|
|
4
4
|
import '../types-CNUqMK0D.mjs';
|
|
5
5
|
import '../types-BKfpdZzX.mjs';
|
|
6
6
|
import '../types-BcmagF6K.mjs';
|
|
@@ -131,6 +131,14 @@ type CreateAnalyticsConfig = {
|
|
|
131
131
|
* review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
|
|
132
132
|
*/
|
|
133
133
|
userContext?: WireUserContext;
|
|
134
|
+
/**
|
|
135
|
+
* ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
|
|
136
|
+
* REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
|
|
137
|
+
* raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
|
|
138
|
+
* `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
|
|
139
|
+
* email address and you accept it as the pseudonymous key. Default `false` (guard on).
|
|
140
|
+
*/
|
|
141
|
+
allowEmailAsUserId?: boolean;
|
|
134
142
|
};
|
|
135
143
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
136
144
|
type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
|
|
@@ -152,6 +160,16 @@ type Analytics = {
|
|
|
152
160
|
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
153
161
|
*/
|
|
154
162
|
setUserContext(partial: Partial<WireUserContext>): void;
|
|
163
|
+
/**
|
|
164
|
+
* LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
|
|
165
|
+
* Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
|
|
166
|
+
* `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
|
|
167
|
+
* which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
|
|
168
|
+
* key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
|
|
169
|
+
* next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
|
|
170
|
+
* screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
|
|
171
|
+
*/
|
|
172
|
+
reset(): void;
|
|
155
173
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
156
174
|
flush(): void;
|
|
157
175
|
/** 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
|
|
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';
|
|
4
4
|
import '../types-Buj9Lw9t.js';
|
|
5
5
|
import '../types-BKfpdZzX.js';
|
|
6
6
|
import '../types-BcmagF6K.js';
|
|
@@ -131,6 +131,14 @@ type CreateAnalyticsConfig = {
|
|
|
131
131
|
* review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
|
|
132
132
|
*/
|
|
133
133
|
userContext?: WireUserContext;
|
|
134
|
+
/**
|
|
135
|
+
* ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
|
|
136
|
+
* REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
|
|
137
|
+
* raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
|
|
138
|
+
* `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
|
|
139
|
+
* email address and you accept it as the pseudonymous key. Default `false` (guard on).
|
|
140
|
+
*/
|
|
141
|
+
allowEmailAsUserId?: boolean;
|
|
134
142
|
};
|
|
135
143
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
136
144
|
type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
|
|
@@ -152,6 +160,16 @@ type Analytics = {
|
|
|
152
160
|
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
153
161
|
*/
|
|
154
162
|
setUserContext(partial: Partial<WireUserContext>): void;
|
|
163
|
+
/**
|
|
164
|
+
* LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
|
|
165
|
+
* Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
|
|
166
|
+
* `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
|
|
167
|
+
* which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
|
|
168
|
+
* key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
|
|
169
|
+
* next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
|
|
170
|
+
* screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
|
|
171
|
+
*/
|
|
172
|
+
reset(): void;
|
|
155
173
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
156
174
|
flush(): void;
|
|
157
175
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
package/dist/analytics/index.js
CHANGED
|
@@ -400,13 +400,45 @@ var init_Constants = __esm({
|
|
|
400
400
|
}
|
|
401
401
|
});
|
|
402
402
|
|
|
403
|
-
// src/
|
|
404
|
-
var
|
|
405
|
-
|
|
403
|
+
// src/analytics/reportClientEvent.ts
|
|
404
|
+
var makeSessionId = () => `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
405
|
+
var buildEventsRequest = (target, events) => {
|
|
406
|
+
if (!(target == null ? void 0 : target.serverUrl) || events.length === 0) return null;
|
|
406
407
|
try {
|
|
407
408
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
408
409
|
const headers = { "Content-Type": "application/json" };
|
|
409
410
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
411
|
+
return { url, init: { method: "POST", headers, body: JSON.stringify({ events }) } };
|
|
412
|
+
} catch {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
var reportClientEvents = (target, events) => {
|
|
417
|
+
try {
|
|
418
|
+
const req = buildEventsRequest(target, events);
|
|
419
|
+
if (!req) return;
|
|
420
|
+
void fetch(req.url, req.init).catch(() => {
|
|
421
|
+
});
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
var reportClientEvent = (target, event) => reportClientEvents(target, [event]);
|
|
426
|
+
var reportClientEventsAwait = async (target, events) => {
|
|
427
|
+
try {
|
|
428
|
+
const req = buildEventsRequest(target, events);
|
|
429
|
+
if (!req) return false;
|
|
430
|
+
const res = await fetch(req.url, req.init);
|
|
431
|
+
return Boolean(res && res.ok);
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
var reportClientEventAwait = (target, event) => reportClientEventsAwait(target, [event]);
|
|
437
|
+
|
|
438
|
+
// src/reviews/transport.ts
|
|
439
|
+
var reportAppEvent = (target, name2, options = {}) => {
|
|
440
|
+
if (!(target == null ? void 0 : target.serverUrl) || !name2) return;
|
|
441
|
+
try {
|
|
410
442
|
const event = {
|
|
411
443
|
event_type: "app_event",
|
|
412
444
|
question_key: name2
|
|
@@ -416,11 +448,9 @@ var reportAppEvent = (target, name2, options = {}) => {
|
|
|
416
448
|
if (options.meta && Object.keys(options.meta).length > 0) {
|
|
417
449
|
event.meta = JSON.stringify(options.meta);
|
|
418
450
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
body: JSON.stringify({ events: [event] })
|
|
423
|
-
}).catch(() => {
|
|
451
|
+
const req = buildEventsRequest(target, [event]);
|
|
452
|
+
if (!req) return;
|
|
453
|
+
void fetch(req.url, req.init).catch(() => {
|
|
424
454
|
});
|
|
425
455
|
} catch {
|
|
426
456
|
}
|
|
@@ -497,41 +527,6 @@ var useScreenTracking = (navigationRef, options = {}) => {
|
|
|
497
527
|
}, [navigationRef]);
|
|
498
528
|
};
|
|
499
529
|
|
|
500
|
-
// src/analytics/reportClientEvent.ts
|
|
501
|
-
var makeSessionId = () => `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
502
|
-
var buildEventsRequest = (target, events) => {
|
|
503
|
-
if (!(target == null ? void 0 : target.serverUrl) || events.length === 0) return null;
|
|
504
|
-
try {
|
|
505
|
-
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
506
|
-
const headers = { "Content-Type": "application/json" };
|
|
507
|
-
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
508
|
-
return { url, init: { method: "POST", headers, body: JSON.stringify({ events }) } };
|
|
509
|
-
} catch {
|
|
510
|
-
return null;
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
var reportClientEvents = (target, events) => {
|
|
514
|
-
try {
|
|
515
|
-
const req = buildEventsRequest(target, events);
|
|
516
|
-
if (!req) return;
|
|
517
|
-
void fetch(req.url, req.init).catch(() => {
|
|
518
|
-
});
|
|
519
|
-
} catch {
|
|
520
|
-
}
|
|
521
|
-
};
|
|
522
|
-
var reportClientEvent = (target, event) => reportClientEvents(target, [event]);
|
|
523
|
-
var reportClientEventsAwait = async (target, events) => {
|
|
524
|
-
try {
|
|
525
|
-
const req = buildEventsRequest(target, events);
|
|
526
|
-
if (!req) return false;
|
|
527
|
-
const res = await fetch(req.url, req.init);
|
|
528
|
-
return Boolean(res && res.ok);
|
|
529
|
-
} catch {
|
|
530
|
-
return false;
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
var reportClientEventAwait = (target, event) => reportClientEventsAwait(target, [event]);
|
|
534
|
-
|
|
535
530
|
// src/analytics/analyticsEvent.ts
|
|
536
531
|
var WIRE_ONBOARDING_EVENTS = {
|
|
537
532
|
started: "wire_onboarding_started",
|
|
@@ -822,6 +817,7 @@ var createEventQueue = (options) => {
|
|
|
822
817
|
var _a3;
|
|
823
818
|
const env = resolveEnvelope();
|
|
824
819
|
const stamped = { ...event };
|
|
820
|
+
if (stamped.ts === void 0) stamped.ts = Date.now();
|
|
825
821
|
if (!env) return stamped;
|
|
826
822
|
if (!stamped.device && env.device) stamped.device = env.device;
|
|
827
823
|
if (!stamped.session_id && env.sessionId) stamped.session_id = env.sessionId;
|
|
@@ -877,19 +873,12 @@ var createEventQueue = (options) => {
|
|
|
877
873
|
}
|
|
878
874
|
})();
|
|
879
875
|
const postBatch = async (events) => {
|
|
880
|
-
|
|
876
|
+
const req = buildEventsRequest(target, events);
|
|
877
|
+
if (!req) return false;
|
|
881
878
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
|
|
882
879
|
const timer = setTimeout(() => controller == null ? void 0 : controller.abort(), 15e3);
|
|
883
880
|
try {
|
|
884
|
-
const
|
|
885
|
-
const headers = { "Content-Type": "application/json" };
|
|
886
|
-
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
887
|
-
const res = await fetch(url, {
|
|
888
|
-
method: "POST",
|
|
889
|
-
headers,
|
|
890
|
-
body: JSON.stringify({ events }),
|
|
891
|
-
signal: controller == null ? void 0 : controller.signal
|
|
892
|
-
});
|
|
881
|
+
const res = await fetch(req.url, { ...req.init, signal: controller == null ? void 0 : controller.signal });
|
|
893
882
|
return !!(res && res.ok);
|
|
894
883
|
} catch {
|
|
895
884
|
return false;
|
|
@@ -991,6 +980,7 @@ var sanitizeUserId = (raw) => {
|
|
|
991
980
|
if (!trimmed) return void 0;
|
|
992
981
|
return trimmed.length > USER_ID_MAX_LENGTH ? trimmed.slice(0, USER_ID_MAX_LENGTH) : trimmed;
|
|
993
982
|
};
|
|
983
|
+
var looksLikeEmail = (value) => typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
994
984
|
|
|
995
985
|
// src/context/userContext.ts
|
|
996
986
|
var EXTRA_KEY_PREFIX = "custom.";
|
|
@@ -1025,6 +1015,21 @@ var namespaceExtra = (extra) => {
|
|
|
1025
1015
|
}
|
|
1026
1016
|
return out;
|
|
1027
1017
|
};
|
|
1018
|
+
var analyticsUserIdStorageKey = (appId) => `wireai:analytics:userId:${appId != null ? appId : "default"}`;
|
|
1019
|
+
var clearPiiFromContext = (ctx = {}) => {
|
|
1020
|
+
const rest = {};
|
|
1021
|
+
if (typeof ctx.appVersion === "string") rest.appVersion = ctx.appVersion;
|
|
1022
|
+
if (typeof ctx.deviceKey === "string") rest.deviceKey = ctx.deviceKey;
|
|
1023
|
+
return rest;
|
|
1024
|
+
};
|
|
1025
|
+
var clearUserContext = async (opts = {}) => {
|
|
1026
|
+
const storage = opts.storage;
|
|
1027
|
+
if (!storage) return;
|
|
1028
|
+
try {
|
|
1029
|
+
await storage.removeItem(analyticsUserIdStorageKey(opts.appId));
|
|
1030
|
+
} catch {
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1028
1033
|
var resolveUserContext = (ctx = {}, opts = {}) => {
|
|
1029
1034
|
var _a2;
|
|
1030
1035
|
const result = {};
|
|
@@ -1065,8 +1070,13 @@ var mintDeviceId = () => {
|
|
|
1065
1070
|
};
|
|
1066
1071
|
|
|
1067
1072
|
// src/analytics/analyticsFacade.ts
|
|
1073
|
+
var warnInDev = (message) => {
|
|
1074
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
1075
|
+
console.warn(message);
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1068
1078
|
var createAnalytics = (config, options = {}) => {
|
|
1069
|
-
var _a2, _b, _c, _d
|
|
1079
|
+
var _a2, _b, _c, _d;
|
|
1070
1080
|
const instanceSessionId = (_a2 = config.sessionId) != null ? _a2 : makeSessionId();
|
|
1071
1081
|
const resolveSessionId = () => {
|
|
1072
1082
|
var _a3, _b2;
|
|
@@ -1103,7 +1113,7 @@ var createAnalytics = (config, options = {}) => {
|
|
|
1103
1113
|
...options
|
|
1104
1114
|
});
|
|
1105
1115
|
let boundUserId = sanitizeUserId((_d = config.userContext) == null ? void 0 : _d.userId);
|
|
1106
|
-
const storageKey =
|
|
1116
|
+
const storageKey = analyticsUserIdStorageKey(config.appId);
|
|
1107
1117
|
if (config.storage) {
|
|
1108
1118
|
void config.storage.getItem(storageKey).then((saved) => {
|
|
1109
1119
|
if (saved && !boundUserId) boundUserId = saved;
|
|
@@ -1122,6 +1132,13 @@ var createAnalytics = (config, options = {}) => {
|
|
|
1122
1132
|
}
|
|
1123
1133
|
if (boundUserId && !event.user_id) event.user_id = boundUserId;
|
|
1124
1134
|
};
|
|
1135
|
+
const guardUserId = (clean) => {
|
|
1136
|
+
if (config.allowEmailAsUserId || !looksLikeEmail(clean)) return clean;
|
|
1137
|
+
warnInDev(
|
|
1138
|
+
"[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
|
+
);
|
|
1140
|
+
return void 0;
|
|
1141
|
+
};
|
|
1125
1142
|
const setUserContext = (partial) => {
|
|
1126
1143
|
var _a3, _b2;
|
|
1127
1144
|
if (!partial || typeof partial !== "object") return;
|
|
@@ -1130,11 +1147,20 @@ var createAnalytics = (config, options = {}) => {
|
|
|
1130
1147
|
if (mergedExtra) userContext.extra = mergedExtra;
|
|
1131
1148
|
const uid = sanitizeUserId(partial.userId);
|
|
1132
1149
|
if (uid) {
|
|
1133
|
-
|
|
1134
|
-
if (
|
|
1135
|
-
|
|
1150
|
+
const bindable = guardUserId(uid);
|
|
1151
|
+
if (bindable) {
|
|
1152
|
+
boundUserId = bindable;
|
|
1153
|
+
if (config.storage) void config.storage.setItem(storageKey, bindable).catch(() => {
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1136
1156
|
}
|
|
1137
1157
|
};
|
|
1158
|
+
const reset = () => {
|
|
1159
|
+
boundUserId = void 0;
|
|
1160
|
+
userContext = clearPiiFromContext(userContext);
|
|
1161
|
+
if (config.storage) void config.storage.removeItem(storageKey).catch(() => {
|
|
1162
|
+
});
|
|
1163
|
+
};
|
|
1138
1164
|
const track = (event, props) => {
|
|
1139
1165
|
if (!event) return;
|
|
1140
1166
|
const clientEvent = {
|
|
@@ -1161,9 +1187,11 @@ var createAnalytics = (config, options = {}) => {
|
|
|
1161
1187
|
const identify = (userId, traits) => {
|
|
1162
1188
|
const clean = sanitizeUserId(userId);
|
|
1163
1189
|
if (!clean) return;
|
|
1164
|
-
|
|
1190
|
+
const bindable = guardUserId(clean);
|
|
1191
|
+
if (!bindable) return;
|
|
1192
|
+
boundUserId = bindable;
|
|
1165
1193
|
if (config.storage) {
|
|
1166
|
-
void config.storage.setItem(storageKey,
|
|
1194
|
+
void config.storage.setItem(storageKey, bindable).catch(() => {
|
|
1167
1195
|
});
|
|
1168
1196
|
}
|
|
1169
1197
|
const clientEvent = {
|
|
@@ -1171,7 +1199,7 @@ var createAnalytics = (config, options = {}) => {
|
|
|
1171
1199
|
// Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
|
|
1172
1200
|
// the session it already saw instead of back-filling a phantom `session_started`.
|
|
1173
1201
|
session_id: resolveSessionId(),
|
|
1174
|
-
user_id:
|
|
1202
|
+
user_id: bindable
|
|
1175
1203
|
};
|
|
1176
1204
|
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
1177
1205
|
applyContext(clientEvent);
|
|
@@ -1182,6 +1210,7 @@ var createAnalytics = (config, options = {}) => {
|
|
|
1182
1210
|
screen,
|
|
1183
1211
|
identify,
|
|
1184
1212
|
setUserContext,
|
|
1213
|
+
reset,
|
|
1185
1214
|
flush: queue.flush,
|
|
1186
1215
|
notifyOnline: queue.notifyOnline,
|
|
1187
1216
|
size: queue.size
|
|
@@ -1199,12 +1228,16 @@ var useAnalytics = (config, options = {}) => {
|
|
|
1199
1228
|
};
|
|
1200
1229
|
|
|
1201
1230
|
exports.WIRE_ONBOARDING_EVENTS = WIRE_ONBOARDING_EVENTS;
|
|
1231
|
+
exports.analyticsUserIdStorageKey = analyticsUserIdStorageKey;
|
|
1202
1232
|
exports.buildContextEnvelope = buildContextEnvelope;
|
|
1233
|
+
exports.clearPiiFromContext = clearPiiFromContext;
|
|
1234
|
+
exports.clearUserContext = clearUserContext;
|
|
1203
1235
|
exports.createAnalytics = createAnalytics;
|
|
1204
1236
|
exports.createEventQueue = createEventQueue;
|
|
1205
1237
|
exports.createScreenTracker = createScreenTracker;
|
|
1206
1238
|
exports.getActiveRouteName = getActiveRouteName;
|
|
1207
1239
|
exports.getCurrentSessionId = getCurrentSessionId;
|
|
1240
|
+
exports.looksLikeEmail = looksLikeEmail;
|
|
1208
1241
|
exports.makeSessionId = makeSessionId;
|
|
1209
1242
|
exports.reportAppEvent = reportAppEvent;
|
|
1210
1243
|
exports.reportClientEvent = reportClientEvent;
|