@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +33 -4
  3. package/dist/analytics/index.d.mts +21 -3
  4. package/dist/analytics/index.d.ts +21 -3
  5. package/dist/analytics/index.js +94 -61
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs +91 -62
  8. package/dist/analytics/index.mjs.map +1 -1
  9. package/dist/{currentSession-DdDkprpM.d.mts → currentSession-C0_odnIW.d.mts} +101 -1
  10. package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-DdnUq2HQ.d.ts} +101 -1
  11. package/dist/index.d.mts +3 -49
  12. package/dist/index.d.ts +3 -49
  13. package/dist/index.js +50 -41
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +47 -42
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/questionnaire/index.js +10 -2
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs +10 -2
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.d.mts +1 -1
  22. package/dist/reviews/index.d.ts +1 -1
  23. package/dist/reviews/index.js +24 -9
  24. package/dist/reviews/index.js.map +1 -1
  25. package/dist/reviews/index.mjs +24 -9
  26. package/dist/reviews/index.mjs.map +1 -1
  27. package/dist/{transport-Bzb-bcB2.d.mts → transport-BGW9uXZJ.d.mts} +0 -15
  28. package/dist/{transport-B31G0Cib.d.ts → transport-jUJd5kxu.d.ts} +0 -15
  29. package/llms.txt +8 -0
  30. package/package.json +1 -1
  31. package/src/analytics/analyticsFacade.ts +72 -9
  32. package/src/analytics/eventQueue.ts +12 -11
  33. package/src/analytics/index.ts +9 -0
  34. package/src/analytics/reportClientEvent.ts +12 -2
  35. package/src/context/userContext.ts +55 -0
  36. package/src/identity/userIdentity.ts +10 -0
  37. package/src/index.ts +5 -1
  38. package/src/questionnaire/transport.ts +5 -1
  39. package/src/reviews/decision.ts +8 -1
  40. package/src/reviews/transport.ts +6 -8
  41. package/src/session-analytics/lifecycle.ts +6 -11
  42. 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. Nothing here identifies a user or device uniquely, so adopting it 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.
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-Bzb-bcB2.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DdDkprpM.mjs';
3
- export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEventAwait, n as reportClientEvents, o as reportClientEventsAwait, p as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-DdDkprpM.mjs';
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-B31G0Cib.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D0Vq7_VE.js';
3
- export { A as AnalyticsEvent, C as ClientEvent, a as ClientEventTarget, b as ClientEventType, c as ContextEnvelope, d as ContextEnvelopeInput, e as EnvelopeSource, f as EventQueue, g as WIRE_ONBOARDING_EVENTS, h as WireOnboardingEventName, i as buildContextEnvelope, j as createEventQueue, k as getCurrentSessionId, m as makeSessionId, r as reportClientEvent, l as reportClientEventAwait, n as reportClientEvents, o as reportClientEventsAwait, p as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-D0Vq7_VE.js';
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. */
@@ -400,13 +400,45 @@ var init_Constants = __esm({
400
400
  }
401
401
  });
402
402
 
403
- // src/reviews/transport.ts
404
- var reportAppEvent = (target, name2, options = {}) => {
405
- if (!(target == null ? void 0 : target.serverUrl) || !name2) return;
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
- void fetch(url, {
420
- method: "POST",
421
- headers,
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
- if (!(target == null ? void 0 : target.serverUrl) || events.length === 0) return false;
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 url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
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, _e;
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 = `wireai:analytics:userId:${(_e = config.appId) != null ? _e : "default"}`;
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
- boundUserId = uid;
1134
- if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {
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
- boundUserId = clean;
1190
+ const bindable = guardUserId(clean);
1191
+ if (!bindable) return;
1192
+ boundUserId = bindable;
1165
1193
  if (config.storage) {
1166
- void config.storage.setItem(storageKey, clean).catch(() => {
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: clean
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;