@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.
Files changed (61) hide show
  1. package/AGENTS.md +51 -0
  2. package/CHANGELOG.md +111 -1
  3. package/INTEGRATION_PROMPT.md +13 -1
  4. package/README.md +106 -4
  5. package/dist/analytics/index.d.mts +35 -6
  6. package/dist/analytics/index.d.ts +35 -6
  7. package/dist/analytics/index.js +222 -94
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +214 -95
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-D6RiVtc8.d.ts} +187 -29
  12. package/dist/{currentSession-DdDkprpM.d.mts → currentSession-DsSDHqor.d.mts} +187 -29
  13. package/dist/index.d.mts +236 -82
  14. package/dist/index.d.ts +236 -82
  15. package/dist/index.js +330 -60
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +314 -61
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +59 -8
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +59 -8
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +2 -2
  26. package/dist/reviews/index.d.ts +2 -2
  27. package/dist/reviews/index.js +97 -17
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +97 -17
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/{transport-Bzb-bcB2.d.mts → transport-CF_eHwzC.d.mts} +15 -16
  32. package/dist/{transport-B31G0Cib.d.ts → transport-DsRe4epC.d.ts} +15 -16
  33. package/llms.txt +9 -0
  34. package/package.json +1 -1
  35. package/src/activation/useWireActivation.ts +12 -1
  36. package/src/activation/wireActivation.ts +36 -24
  37. package/src/analytics/analyticsFacade.ts +113 -29
  38. package/src/analytics/currentSession.ts +83 -0
  39. package/src/analytics/eventQueue.ts +20 -11
  40. package/src/analytics/index.ts +29 -1
  41. package/src/analytics/reportClientEvent.ts +50 -2
  42. package/src/analytics/screenTracking.ts +6 -1
  43. package/src/analytics/useAnalytics.ts +22 -1
  44. package/src/context/deviceId.ts +109 -0
  45. package/src/context/userContext.ts +73 -0
  46. package/src/identity/userIdentity.ts +10 -0
  47. package/src/index.ts +50 -2
  48. package/src/questionnaire/runtime.ts +12 -2
  49. package/src/questionnaire/transport.ts +5 -1
  50. package/src/questionnaire/useQuestionnaireGate.ts +9 -7
  51. package/src/revenuecat/index.ts +55 -0
  52. package/src/revenuecat/purchaseEvents.ts +167 -0
  53. package/src/revenuecat/revenueCatBridge.ts +221 -0
  54. package/src/revenuecat/types.ts +95 -0
  55. package/src/reviews/decision.ts +8 -1
  56. package/src/reviews/runtime.ts +92 -1
  57. package/src/reviews/transport.ts +27 -10
  58. package/src/reviews/useReviewGate.ts +12 -7
  59. package/src/session-analytics/lifecycle.ts +15 -13
  60. package/src/session-analytics/reportSessionStart.ts +15 -4
  61. package/src/session-analytics/useLifecycleEvents.ts +68 -28
@@ -1,20 +1,5 @@
1
1
  import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-CNUqMK0D.mjs';
2
2
 
3
- /**
4
- * transport.ts — kit → Wire server requests for the review module, all fire-and-forget
5
- * (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
6
- * thin fetch wrapper, Bearer tenant key, swallow every error.
7
- *
8
- * • submitReview → POST {serverUrl}/v1/reviews (the review row)
9
- * • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
10
- * • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
11
- *
12
- * `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
13
- * events through the SAME transport (stored server-side as event_type='app_event',
14
- * question_key=<name>), which is what the backend review-firing rules evaluate on — and
15
- * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
16
- */
17
-
18
3
  /**
19
4
  * POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
20
5
  * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
@@ -109,7 +94,11 @@ interface FetchReviewDecisionOptions {
109
94
  declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
110
95
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
111
96
  interface ReportAppEventOptions {
112
- /** The onboarding/session id to correlate with, when known. */
97
+ /**
98
+ * The onboarding/session id to correlate with, when known. Optional: when omitted the event
99
+ * still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
100
+ * with no `session_id` is dropped server-side behind a 200. Pass one only to override.
101
+ */
113
102
  sessionId?: string;
114
103
  /** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
115
104
  deviceKey?: string;
@@ -122,6 +111,16 @@ interface ReportAppEventOptions {
122
111
  * stable identifier and `meta` small + non-PII.
123
112
  *
124
113
  * reportAppEvent(target, "content_share", { sessionId, deviceKey });
114
+ *
115
+ * ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
116
+ * The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
117
+ * validates per event inside a try/except that counts the failure as `skipped` and STILL returns
118
+ * HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
119
+ * fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
120
+ * `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
121
+ * posted every screen view into that hole. So the id is no longer conditional — an explicit
122
+ * `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
123
+ * has been registered yet).
125
124
  */
126
125
  declare const reportAppEvent: (target: ReviewTarget | undefined, name: string, options?: ReportAppEventOptions) => void;
127
126
 
@@ -1,20 +1,5 @@
1
1
  import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Buj9Lw9t.js';
2
2
 
3
- /**
4
- * transport.ts — kit → Wire server requests for the review module, all fire-and-forget
5
- * (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
6
- * thin fetch wrapper, Bearer tenant key, swallow every error.
7
- *
8
- * • submitReview → POST {serverUrl}/v1/reviews (the review row)
9
- * • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
10
- * • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
11
- *
12
- * `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
13
- * events through the SAME transport (stored server-side as event_type='app_event',
14
- * question_key=<name>), which is what the backend review-firing rules evaluate on — and
15
- * it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
16
- */
17
-
18
3
  /**
19
4
  * POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
20
5
  * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
@@ -109,7 +94,11 @@ interface FetchReviewDecisionOptions {
109
94
  declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
110
95
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
111
96
  interface ReportAppEventOptions {
112
- /** The onboarding/session id to correlate with, when known. */
97
+ /**
98
+ * The onboarding/session id to correlate with, when known. Optional: when omitted the event
99
+ * still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
100
+ * with no `session_id` is dropped server-side behind a 200. Pass one only to override.
101
+ */
113
102
  sessionId?: string;
114
103
  /** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
115
104
  deviceKey?: string;
@@ -122,6 +111,16 @@ interface ReportAppEventOptions {
122
111
  * stable identifier and `meta` small + non-PII.
123
112
  *
124
113
  * reportAppEvent(target, "content_share", { sessionId, deviceKey });
114
+ *
115
+ * ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
116
+ * The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
117
+ * validates per event inside a try/except that counts the failure as `skipped` and STILL returns
118
+ * HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
119
+ * fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
120
+ * `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
121
+ * posted every screen view into that hole. So the id is no longer conditional — an explicit
122
+ * `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
123
+ * has been registered yet).
125
124
  */
126
125
  declare const reportAppEvent: (target: ReviewTarget | undefined, name: string, options?: ReportAppEventOptions) => void;
127
126
 
package/llms.txt CHANGED
@@ -23,6 +23,15 @@
23
23
  - `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
24
24
  - `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
25
25
  - `deriveAnswers(messages)`, `themeFromBrand({ primary })`, `defaultIllustrations`, `DemoOnboarding` (dev/QA, no account).
26
+
27
+ ## Subpath surfaces (beyond onboarding)
28
+
29
+ The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
30
+
31
+ - `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section.
32
+ - `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
33
+ - `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
34
+ - `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`.
26
35
  - `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
27
36
 
28
37
  ## Files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -59,7 +59,18 @@ export const useWireActivation = (config: WireActivationConfig): UseWireActivati
59
59
  const ref = useRef<WireActivation | undefined>(undefined);
60
60
  const prevKeys = useRef<string>("");
61
61
 
62
- const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}|${config.deviceKey}`;
62
+ // The identity of the built instance. `userContext.deviceKey` MUST be in here: `createWireActivation`
63
+ // honors it as an explicit device key (`config.deviceKey ?? config.userContext?.deviceKey`), so a host
64
+ // that hydrates its device id asynchronously and passes it only inside `userContext` would otherwise
65
+ // keep an instance frozen on the first render's `undefined` — the stale-static-ref failure this repo
66
+ // has already been bitten by (see .memory/70-knowledge.md, "Stale Option Closures in Static Refs").
67
+ const currentKeys = [
68
+ config.serverUrl,
69
+ config.apiKey,
70
+ config.appId,
71
+ config.deviceKey,
72
+ config.userContext?.deviceKey,
73
+ ].join("|");
63
74
  if (!ref.current || prevKeys.current !== currentKeys) {
64
75
  prevKeys.current = currentKeys;
65
76
  ref.current = createWireActivation(config);
@@ -12,7 +12,7 @@
12
12
  * await wire.track("journal_done"); // awaitable POST + auto-revalidate
13
13
  *
14
14
  * `track` POSTs `event_type='app_event'`, `question_key=<name>` (the EXACT string a review /
15
- * questionnaire firing TRIGGER matches on) under the CURRENT `getCurrentSessionId()` — the same id
15
+ * questionnaire firing TRIGGER matches on) under the CURRENT `ensureCurrentSessionId()` — the same id
16
16
  * the gates pass to their `/decision` fetch, so the server's session-scoped trigger rule agrees —
17
17
  * with `user_context.device_key` for the min-sessions / arm-assignment lookups. On a successful POST
18
18
  * it bumps decision revalidation so a subscribed gate re-fetches and can fire.
@@ -22,13 +22,13 @@
22
22
  * and `getCurrentSessionId` — it introduces NO second session concept and duplicates no POST path.
23
23
  * React-free (the optional React glue is the thin `useWireActivation` hook).
24
24
  */
25
- import { getCurrentSessionId } from "../analytics/currentSession";
25
+ import { ensureCurrentSessionId, getCurrentSessionId } from "../analytics/currentSession";
26
26
  import {
27
27
  reportClientEventAwait,
28
28
  type ClientEvent,
29
29
  type ClientEventTarget,
30
30
  } from "../analytics/reportClientEvent";
31
- import { deviceIdStorageKey, mintDeviceId } from "../context/deviceId";
31
+ import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
32
32
  import { resolveUserContext, type WireUserContext } from "../context/userContext";
33
33
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
34
  import {
@@ -78,8 +78,14 @@ export type WireActivation = {
78
78
  /**
79
79
  * Awaitable action report: POST `event_type='app_event'`, `question_key=<name>`, optional `meta`,
80
80
  * under the CURRENT session id + `user_context.device_key`. Resolves `true` once the server has
81
- * stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump) when there is no
82
- * current session, a blank name, or the POST fails. Never throws.
81
+ * stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump) for a blank
82
+ * name or a failed POST. Never throws.
83
+ *
84
+ * It no longer refuses when no app-open has been registered: the session id is resolved through
85
+ * `ensureCurrentSessionId()`, which mints and registers one in that case (the server requires a
86
+ * non-empty `session_id` and silently drops an event without one, so bailing lost the action
87
+ * entirely). A host that fires `reportSessionStart` / `useLifecycleEvents` first is unaffected —
88
+ * the real per-open id is already registered and gets used exactly as before.
83
89
  */
84
90
  track(name: string, meta?: Record<string, unknown>): Promise<boolean>;
85
91
  /** The CURRENT per-open session id (the kit's canonical `getCurrentSessionId()`), or `undefined`. */
@@ -98,27 +104,28 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
98
104
  const target: ClientEventTarget = { serverUrl: config.serverUrl, apiKey: config.apiKey };
99
105
 
100
106
  // Device key: an explicit id (top-level or in userContext) wins and is never overwritten; otherwise
101
- // auto-mint ONE and persist via storage (reused every open) so `device_key` is always present.
107
+ // read the ONE process-wide auto id (`resolveAutoDeviceKey`) so this instance and a sibling
108
+ // `createAnalytics` instance carry the SAME `device_key` for the same install. Minting locally here
109
+ // gave one install two auto ids — see the registry note in context/deviceId.ts.
102
110
  const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
103
- let autoDeviceKey = explicitDeviceKey ?? mintDeviceId();
104
- if (config.storage && !explicitDeviceKey) {
105
- const storage = config.storage;
106
- const key = deviceIdStorageKey(config.appId);
107
- void storage
108
- .getItem(key)
109
- .then((saved) => {
110
- const persisted = clean(saved ?? undefined);
111
- if (persisted) autoDeviceKey = persisted;
112
- else void storage.setItem(key, autoDeviceKey).catch(() => {});
113
- })
114
- .catch(() => {});
115
- }
111
+ const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
112
+ appId: config.appId,
113
+ // An explicit key opts out of minting AND persisting (unchanged contract).
114
+ storage: explicitDeviceKey ? undefined : config.storage,
115
+ };
116
+ // Start hydration AT CONSTRUCTION (not at the first `track`) so the persisted id is adopted as early
117
+ // as it used to be. The return value is deliberately discarded — every event re-resolves.
118
+ if (!explicitDeviceKey) resolveAutoDeviceKey(autoDeviceKeyOptions);
116
119
 
117
120
  // Stamp the resolved rich context onto the event: `user_context` bucket (device_key always, plus any
118
121
  // app_version / opt-in user_email / namespaced extra) and the top-level opaque `user_id`.
119
122
  const applyContext = (event: ClientEvent): void => {
120
123
  const resolved = resolveUserContext(
121
- { ...(config.userContext ?? {}), deviceKey: explicitDeviceKey ?? autoDeviceKey },
124
+ // `??` is lazy on purpose: an explicit key must never even touch the auto registry.
125
+ {
126
+ ...(config.userContext ?? {}),
127
+ deviceKey: explicitDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions),
128
+ },
122
129
  { autoAppVersion: config.appVersion },
123
130
  );
124
131
  if (resolved.userContext) {
@@ -128,10 +135,15 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
128
135
  };
129
136
 
130
137
  const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
131
- const sessionId = getCurrentSessionId();
132
- // No current session (no app-open registered yet) or a blank name nothing to correlate; bail
133
- // WITHOUT bumping (a bump with no posted event would only make the gate re-fetch for nothing).
134
- if (!clean(name) || !sessionId) return false;
138
+ // A blank name is the only thing left to refuse on: there is no `question_key` to match a
139
+ // firing trigger against, so bail WITHOUT bumping (a bump with no posted event would only make
140
+ // the gate re-fetch for nothing).
141
+ if (!clean(name)) return false;
142
+ // No app-open registered is NOT a reason to drop the action. `ensureCurrentSessionId` returns
143
+ // the registered per-open id when there is one (the unchanged path) and otherwise mints +
144
+ // registers one, so the POST always carries the non-empty `session_id` the server requires
145
+ // instead of being accepted with a 200 and discarded.
146
+ const sessionId = ensureCurrentSessionId();
135
147
  const event: ClientEvent = {
136
148
  event_type: "app_event",
137
149
  session_id: sessionId,
@@ -30,9 +30,25 @@ import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
30
  import { getCurrentSessionId } from "./currentSession";
31
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
32
32
  import { makeSessionId, type ClientEvent } from "./reportClientEvent";
33
- import { resolveUserContext, type WireUserContext } from "../context/userContext";
34
- import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
35
- import { sanitizeUserId } from "../identity/userIdentity";
33
+ import {
34
+ analyticsUserIdStorageKey,
35
+ clearPiiFromContext,
36
+ resolveUserContext,
37
+ type WireUserContext,
38
+ } from "../context/userContext";
39
+ import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
40
+ import { detectAppVersion } from "../device/appVersion";
41
+ import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
42
+
43
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
44
+ declare const __DEV__: boolean | undefined;
45
+
46
+ /** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
47
+ const warnInDev = (message: string): void => {
48
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
49
+ console.warn(message);
50
+ }
51
+ };
36
52
 
37
53
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
38
54
  export type AnalyticsProps = Record<string, unknown>;
@@ -48,8 +64,14 @@ export type CreateAnalyticsConfig = {
48
64
  /** Tenant API key; sent as `Authorization: Bearer`. */
49
65
  apiKey: string;
50
66
  /**
51
- * Correlation id shared by every event from this instance (and the `identify` event). Defaults
52
- * to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
67
+ * ⚠️ OPT-OUT KNOB, not a default. Supplying a `sessionId` FREEZES the correlation id: every event
68
+ * this instance ever sends (including `identify`) is pinned to that one id, and the instance stops
69
+ * following the LIVE per-open session the server registered via `app.session_started`. Lifecycle
70
+ * analytics then collapse onto a single device-scoped session — one "first open", forever.
71
+ *
72
+ * OMIT IT — that is the correct default. Without it the kit reuses the live per-open session, and
73
+ * falls back to a stable per-instance id only until an open has been registered. Pass one ONLY if
74
+ * your host runs its own session lifecycle and owns the id the server should correlate on.
53
75
  */
54
76
  sessionId?: string;
55
77
  /** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
@@ -76,6 +98,14 @@ export type CreateAnalyticsConfig = {
76
98
  * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
77
99
  */
78
100
  userContext?: WireUserContext;
101
+ /**
102
+ * ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
103
+ * REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
104
+ * raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
105
+ * `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
106
+ * email address and you accept it as the pseudonymous key. Default `false` (guard on).
107
+ */
108
+ allowEmailAsUserId?: boolean;
79
109
  };
80
110
 
81
111
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
@@ -101,6 +131,16 @@ export type Analytics = {
101
131
  * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
102
132
  */
103
133
  setUserContext(partial: Partial<WireUserContext>): void;
134
+ /**
135
+ * LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
136
+ * Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
137
+ * `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
138
+ * which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
139
+ * key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
140
+ * next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
141
+ * screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
142
+ */
143
+ reset(): void;
104
144
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
105
145
  flush(): void;
106
146
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -129,6 +169,22 @@ export const createAnalytics = (
129
169
  const resolveSessionId = (): string =>
130
170
  config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
131
171
 
172
+ // A frozen id is almost always a mistake (it silently flattens every open into ONE session), so
173
+ // name it once at construction — same dev-only channel as the email-shape guard below.
174
+ if (config.sessionId) {
175
+ warnInDev(
176
+ "[wireai] createAnalytics({ sessionId }) PINS every event from this instance to that one " +
177
+ "frozen id and opts out of the live per-open session (app.session_started) — lifecycle " +
178
+ "analytics collapse onto a single device-scoped id. Remove it unless your host runs its " +
179
+ "own session lifecycle.",
180
+ );
181
+ }
182
+
183
+ // The auto-detected host app version, read ONCE here (cheap, sync, never throws). It backs the
184
+ // `user_context.app_version` fallback below: without it a host that passes no `config.appVersion`
185
+ // got the detected version on `device.appVersion` only, leaving the user_context field absent.
186
+ const detectedAppVersion = detectAppVersion();
187
+
132
188
  // The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
133
189
  // every event so a post-mount update (login) takes effect immediately. Declared before the envelope
134
190
  // provider so the provider can read the current `userContext.appVersion` (see below).
@@ -144,21 +200,18 @@ export const createAnalytics = (
144
200
  typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
145
201
  ? config.userContext.deviceKey.trim()
146
202
  : undefined;
147
- // Minted synchronously so `device_key` is never missing, even before the async storage read resolves.
148
- let autoDeviceKey = mintDeviceId();
149
- if (config.storage && !hostDeviceKeyAtInit) {
150
- const storage = config.storage;
151
- const deviceKey = deviceIdStorageKey(config.appId);
152
- void storage
153
- .getItem(deviceKey)
154
- .then((saved) => {
155
- const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
156
- // Reuse the persisted per-install id across opens; on first run persist the freshly minted one.
157
- if (persisted) autoDeviceKey = persisted;
158
- else void storage.setItem(deviceKey, autoDeviceKey).catch(() => {});
159
- })
160
- .catch(() => {});
161
- }
203
+ // The auto id comes from the ONE process-wide registry (`resolveAutoDeviceKey`), NOT a mint local to
204
+ // this instance. A host that also builds a `createWireActivation` instance used to get a SECOND,
205
+ // different auto id for the same install, splitting `device_key` across two id spaces — see the
206
+ // registry note in context/deviceId.ts. Resolved lazily per event so hydration is picked up.
207
+ const autoDeviceKeyOptions: ResolveAutoDeviceKeyOptions = {
208
+ appId: config.appId,
209
+ // A host-supplied deviceKey opts out of minting AND persisting (unchanged contract).
210
+ storage: hostDeviceKeyAtInit ? undefined : config.storage,
211
+ };
212
+ // Start hydration AT CONSTRUCTION (not at the first event) so the persisted id is adopted as early
213
+ // as it used to be. The return value is deliberately discarded — every event re-resolves.
214
+ if (!hostDeviceKeyAtInit) resolveAutoDeviceKey(autoDeviceKeyOptions);
162
215
 
163
216
  // A provider (not a fixed value) so `networkType`, the current session id, AND the effective app
164
217
  // version are evaluated fresh on every enqueue. An explicit `WireUserContext.appVersion` (a host that
@@ -185,7 +238,7 @@ export const createAnalytics = (
185
238
  // Per-session, in-memory user binding. Seeded from the init context, then persisted across
186
239
  // launches when storage is provided.
187
240
  let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
188
- const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
241
+ const storageKey = analyticsUserIdStorageKey(config.appId);
189
242
 
190
243
  if (config.storage) {
191
244
  void config.storage
@@ -208,8 +261,9 @@ export const createAnalytics = (
208
261
  ? userContext.deviceKey
209
262
  : undefined;
210
263
  const resolved = resolveUserContext(
211
- { ...userContext, deviceKey: hostDeviceKey ?? autoDeviceKey },
212
- { autoAppVersion: config.appVersion },
264
+ // `??` is lazy on purpose: a host-supplied key must never even touch the auto registry.
265
+ { ...userContext, deviceKey: hostDeviceKey ?? resolveAutoDeviceKey(autoDeviceKeyOptions) },
266
+ { autoAppVersion: config.appVersion ?? detectedAppVersion },
213
267
  );
214
268
  if (resolved.userContext) {
215
269
  event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
@@ -217,6 +271,19 @@ export const createAnalytics = (
217
271
  if (boundUserId && !event.user_id) event.user_id = boundUserId;
218
272
  };
219
273
 
274
+ // Email-shape guard for the OPAQUE user id. A raw email bound as `user_id` is a PII leak (it
275
+ // belongs in the opt-in `user_context.user_email`), so refuse it and warn in dev — unless the host
276
+ // opted in via `allowEmailAsUserId`. Returns the id to bind, or `undefined` to refuse.
277
+ const guardUserId = (clean: string): string | undefined => {
278
+ if (config.allowEmailAsUserId || !looksLikeEmail(clean)) return clean;
279
+ warnInDev(
280
+ "[wireai] identify() was called with an email-shaped id. A raw email must NOT be the opaque " +
281
+ "user_id (PII leak) — pass it as userContext.userEmail instead. Binding was skipped. Set " +
282
+ "allowEmailAsUserId:true on createAnalytics if your user id genuinely is an email.",
283
+ );
284
+ return undefined;
285
+ };
286
+
220
287
  const setUserContext = (partial: Partial<WireUserContext>): void => {
221
288
  if (!partial || typeof partial !== "object") return;
222
289
  // Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
@@ -226,14 +293,27 @@ export const createAnalytics = (
226
293
  : undefined;
227
294
  userContext = { ...userContext, ...partial };
228
295
  if (mergedExtra) userContext.extra = mergedExtra;
229
- // A user id supplied here binds like `identify` so subsequent events carry `user_id`.
296
+ // A user id supplied here binds like `identify` so subsequent events carry `user_id` — through
297
+ // the SAME email-shape guard (a raw email must not become the opaque user_id).
230
298
  const uid = sanitizeUserId(partial.userId);
231
299
  if (uid) {
232
- boundUserId = uid;
233
- if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
300
+ const bindable = guardUserId(uid);
301
+ if (bindable) {
302
+ boundUserId = bindable;
303
+ if (config.storage) void config.storage.setItem(storageKey, bindable).catch(() => {});
304
+ }
234
305
  }
235
306
  };
236
307
 
308
+ const reset = (): void => {
309
+ // In-memory binding cleared: subsequent events carry no user_id until the next identify.
310
+ boundUserId = undefined;
311
+ // Strip PII from the rich context but keep the device-scope fields (device_key / app_version).
312
+ userContext = clearPiiFromContext(userContext);
313
+ // Remove the persisted binding so a relaunch can't rehydrate the previous user's id.
314
+ if (config.storage) void config.storage.removeItem(storageKey).catch(() => {});
315
+ };
316
+
237
317
  const track = (event: string, props?: AnalyticsProps): void => {
238
318
  if (!event) return;
239
319
  const clientEvent: ClientEvent = {
@@ -264,16 +344,19 @@ export const createAnalytics = (
264
344
  const clean = sanitizeUserId(userId);
265
345
  // Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
266
346
  if (!clean) return;
267
- boundUserId = clean;
347
+ // Email-shape guard: refuse to bind (and emit) a raw email as the opaque user_id unless opted in.
348
+ const bindable = guardUserId(clean);
349
+ if (!bindable) return;
350
+ boundUserId = bindable;
268
351
  if (config.storage) {
269
- void config.storage.setItem(storageKey, clean).catch(() => {});
352
+ void config.storage.setItem(storageKey, bindable).catch(() => {});
270
353
  }
271
354
  const clientEvent: ClientEvent = {
272
355
  event_type: "identify",
273
356
  // Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
274
357
  // the session it already saw instead of back-filling a phantom `session_started`.
275
358
  session_id: resolveSessionId(),
276
- user_id: clean,
359
+ user_id: bindable,
277
360
  };
278
361
  if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
279
362
  applyContext(clientEvent);
@@ -285,6 +368,7 @@ export const createAnalytics = (
285
368
  screen,
286
369
  identify,
287
370
  setUserContext,
371
+ reset,
288
372
  flush: queue.flush,
289
373
  notifyOnline: queue.notifyOnline,
290
374
  size: queue.size,
@@ -32,7 +32,22 @@
32
32
  * PROCESS-LOCAL, NOT PERSISTED: the slot lives on the runtime global, so it tracks the CURRENT
33
33
  * process's open and a fresh open overwrites it. There is no cross-launch state.
34
34
  * `resetCurrentSessionId` clears the slot so a unit test starts from a clean registry.
35
+ *
36
+ * ── WHY `ensureCurrentSessionId` EXISTS (the silent-drop contract) ─────────────────────────────
37
+ * The server's event model declares `session_id: str = Field(min_length=1)` — REQUIRED, non-empty.
38
+ * `POST /v1/events` validates each event inside a try/except that increments a `skipped` counter and
39
+ * still returns HTTP 200. So an event posted without a `session_id` is accepted by the wire and
40
+ * DISCARDED by the server, and a fire-and-forget client can never learn it happened. That is the
41
+ * worst of both: no error, no data. Screen tracking in a host that never mounted the lifecycle hook
42
+ * fell into exactly that hole — every screen view posted, 200'd, and dropped.
43
+ *
44
+ * `ensureCurrentSessionId` closes it: it returns the registered id when an open HAS been registered
45
+ * (unchanged behaviour for every host that fires `reportSessionStart` first), and otherwise mints one,
46
+ * REGISTERS it, and returns it — so every later event in the process correlates to that same id
47
+ * instead of each emitting its own orphan. A minted id is a fallback, not a substitute for a real
48
+ * app-open: it warns once in dev, naming the fix.
35
49
  */
50
+ import { makeSessionId } from "./reportClientEvent";
36
51
 
37
52
  /**
38
53
  * Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
@@ -67,3 +82,71 @@ export const getCurrentSessionId = (): string | undefined =>
67
82
  export const resetCurrentSessionId = (): void => {
68
83
  globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
69
84
  };
85
+
86
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
87
+ declare const __DEV__: boolean | undefined;
88
+
89
+ /**
90
+ * Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
91
+ * Same idiom as `analyticsFacade.warnInDev` — deliberately duplicated rather than imported, so this
92
+ * module keeps its zero-import-weight for the tree-shaken analytics bundle.
93
+ *
94
+ * Returns whether it ACTUALLY warned, so the caller's once-flag is spent on a warning a developer
95
+ * saw. Marking "already warned" after a no-op would burn the single warning in prod, and the one
96
+ * dev build that needed it would then run silent.
97
+ */
98
+ const warnInDev = (message: string): boolean => {
99
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
100
+ console.warn(message);
101
+ return true;
102
+ }
103
+ return false;
104
+ };
105
+
106
+ /** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
107
+ const MINT_WARNING =
108
+ "[wireai] No app-open session was registered, so a session id was minted for this event " +
109
+ "(the server drops an event that has no session_id, and still answers 200). Mount " +
110
+ "useLifecycleEvents at your app root so events correlate to a real app-open.";
111
+
112
+ /**
113
+ * "Have we already warned about a minted session id?" — its OWN `Symbol.for` slot, for the same
114
+ * cross-bundle reason as the id itself: a plain module `let` would warn once per inlined copy, i.e.
115
+ * once per bundle, not once per process. NOT cleared by `resetCurrentSessionId`: "warn once" is a
116
+ * process-lifetime promise, and a test that resets the id between mints is still one process.
117
+ */
118
+ const MINT_WARNED_SLOT: unique symbol = Symbol.for(
119
+ "@wireai/activation:currentSessionIdMintWarned",
120
+ );
121
+
122
+ type GlobalWithWarnSlot = typeof globalThis & { [MINT_WARNED_SLOT]?: boolean };
123
+
124
+ const warnSlot = globalThis as GlobalWithWarnSlot;
125
+
126
+ /**
127
+ * The current per-open `session_id`, MINTING and registering one when no app-open has been
128
+ * registered yet. Always returns a non-empty string. Idempotent (a second call returns the same id)
129
+ * and never throws.
130
+ *
131
+ * Use this on every path that puts a `session_id` on the wire. The server REQUIRES a non-empty
132
+ * `session_id` and drops the event otherwise while still answering 200 (see the module header), so
133
+ * "no id yet" must never mean "send it without one".
134
+ *
135
+ * BACKWARD-COMPATIBLE BY CONSTRUCTION: when `reportSessionStart` / `useLifecycleEvents` has already
136
+ * registered the real per-open id, this is `getCurrentSessionId()` and nothing changes. It only ever
137
+ * mints in the case that used to produce a silently discarded event.
138
+ *
139
+ * A mint means the host never registered an app-open, so the minted id is one the server has not
140
+ * seen a `session_started` for — the events land, but the session is thinner than a real open.
141
+ * Hence the one-time dev warning naming the fix (mount `useLifecycleEvents` at the app root).
142
+ */
143
+ export const ensureCurrentSessionId = (): string => {
144
+ const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
145
+ if (typeof existing === "string" && existing.length > 0) return existing;
146
+ const minted = makeSessionId();
147
+ globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
148
+ if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
149
+ warnSlot[MINT_WARNED_SLOT] = true;
150
+ }
151
+ return minted;
152
+ };