@wireai/activation 0.4.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.4.0",
3
+ "version": "0.7.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>",
@@ -61,9 +61,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
61
61
  const boundUserId = useMemo(() => sanitizeUserId(userId), [userId]);
62
62
 
63
63
  // Privacy-label-neutral device snapshot, collected once per mount (no advertising IDs, no
64
- // fingerprinting — see device/deviceContext.ts). `config.appVersion` is host-injected and
65
- // merged in here (collectDeviceContext never sets it). Attached to BOTH the A2A session
66
- // metadata and every client event so the backend can segment the funnel by device.
64
+ // fingerprinting — see device/deviceContext.ts). `collectDeviceContext` already carries a
65
+ // best-effort auto-detected `appVersion`; an explicit host `config.appVersion` overrides it.
66
+ // Attached to BOTH the A2A session metadata and every client event so the backend can segment
67
+ // the funnel by device (and by app version).
67
68
  const device = useMemo<DeviceContext>(() => {
68
69
  const collected = collectDeviceContext();
69
70
  return config.appVersion ? { ...collected, appVersion: config.appVersion } : collected;
@@ -27,8 +27,10 @@
27
27
  * FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
28
28
  */
29
29
  import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
30
+ import { getCurrentSessionId } from "./currentSession";
30
31
  import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
31
32
  import { makeSessionId, type ClientEvent } from "./reportClientEvent";
33
+ import { resolveUserContext, type WireUserContext } from "../context/userContext";
32
34
  import { sanitizeUserId } from "../identity/userIdentity";
33
35
 
34
36
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
@@ -62,6 +64,13 @@ export type CreateAnalyticsConfig = {
62
64
  appBuild?: string;
63
65
  /** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
64
66
  networkType?: string;
67
+ /**
68
+ * The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
69
+ * user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
70
+ * {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional — omit it
71
+ * and events carry only the device context, exactly as before.
72
+ */
73
+ userContext?: WireUserContext;
65
74
  };
66
75
 
67
76
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
@@ -81,6 +90,12 @@ export type Analytics = {
81
90
  screen(name: string, props?: AnalyticsProps): void;
82
91
  /** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
83
92
  identify(userId: string, traits?: AnalyticsProps): void;
93
+ /**
94
+ * Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
95
+ * merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
96
+ * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
97
+ */
98
+ setUserContext(partial: Partial<WireUserContext>): void;
84
99
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
85
100
  flush(): void;
86
101
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -98,12 +113,22 @@ export const createAnalytics = (
98
113
  config: CreateAnalyticsConfig,
99
114
  options: AnalyticsOptions = {},
100
115
  ): Analytics => {
101
- const sessionId = config.sessionId ?? makeSessionId();
116
+ // A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
117
+ // no per-open session has been registered yet (see `resolveSessionId`).
118
+ const instanceSessionId = config.sessionId ?? makeSessionId();
119
+
120
+ // The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
121
+ // id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
122
+ // `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
123
+ // phantom session; finally fall back to the stable per-instance id when no open is registered yet.
124
+ const resolveSessionId = (): string =>
125
+ config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
102
126
 
103
- // A provider (not a fixed value) so `networkType` is evaluated fresh on every enqueue.
127
+ // A provider (not a fixed value) so `networkType` + the current session id are evaluated fresh
128
+ // on every enqueue.
104
129
  const envelope = (): ContextEnvelope =>
105
130
  buildContextEnvelope({
106
- sessionId,
131
+ sessionId: resolveSessionId(),
107
132
  appVersion: config.appVersion,
108
133
  appBuild: config.appBuild,
109
134
  networkType: config.networkType,
@@ -117,28 +142,62 @@ export const createAnalytics = (
117
142
  ...options,
118
143
  });
119
144
 
120
- // Per-session, in-memory user binding. Persisted across launches when storage is provided.
121
- let boundUserId: string | undefined;
145
+ // The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
146
+ // every event so a post-mount update (login) takes effect immediately.
147
+ let userContext: WireUserContext = { ...(config.userContext ?? {}) };
148
+
149
+ // Per-session, in-memory user binding. Seeded from the init context, then persisted across
150
+ // launches when storage is provided.
151
+ let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
122
152
  const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
123
153
 
124
154
  if (config.storage) {
125
155
  void config.storage
126
156
  .getItem(storageKey)
127
157
  .then((saved) => {
128
- if (saved) boundUserId = saved;
158
+ // Don't clobber an explicit init-context user id with a stale persisted one.
159
+ if (saved && !boundUserId) boundUserId = saved;
129
160
  })
130
161
  .catch(() => {});
131
162
  }
132
163
 
164
+ // Stamp the resolved rich context onto an event: the `user_context` bucket (device_key, app_version,
165
+ // opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
166
+ // key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
167
+ const applyContext = (event: ClientEvent): void => {
168
+ const resolved = resolveUserContext(userContext, { autoAppVersion: config.appVersion });
169
+ if (resolved.userContext) {
170
+ event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
171
+ }
172
+ if (boundUserId && !event.user_id) event.user_id = boundUserId;
173
+ };
174
+
175
+ const setUserContext = (partial: Partial<WireUserContext>): void => {
176
+ if (!partial || typeof partial !== "object") return;
177
+ // Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
178
+ const mergedExtra =
179
+ partial.extra || userContext.extra
180
+ ? { ...(userContext.extra ?? {}), ...(partial.extra ?? {}) }
181
+ : undefined;
182
+ userContext = { ...userContext, ...partial };
183
+ if (mergedExtra) userContext.extra = mergedExtra;
184
+ // A user id supplied here binds like `identify` so subsequent events carry `user_id`.
185
+ const uid = sanitizeUserId(partial.userId);
186
+ if (uid) {
187
+ boundUserId = uid;
188
+ if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
189
+ }
190
+ };
191
+
133
192
  const track = (event: string, props?: AnalyticsProps): void => {
134
193
  if (!event) return;
135
194
  const clientEvent: ClientEvent = {
136
195
  event_type: "app_event",
137
- session_id: sessionId,
196
+ session_id: resolveSessionId(),
138
197
  question_key: event,
139
198
  };
140
199
  if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
141
- if (boundUserId) clientEvent.user_id = boundUserId;
200
+ applyContext(clientEvent);
142
201
  queue.enqueue(clientEvent);
143
202
  };
144
203
 
@@ -148,11 +207,11 @@ export const createAnalytics = (
148
207
  const meta = { screen: name, ...(props ?? {}) };
149
208
  const clientEvent: ClientEvent = {
150
209
  event_type: "app_event",
151
- session_id: sessionId,
210
+ session_id: resolveSessionId(),
152
211
  question_key: "screen",
153
212
  meta: JSON.stringify(meta),
154
213
  };
155
- if (boundUserId) clientEvent.user_id = boundUserId;
214
+ applyContext(clientEvent);
156
215
  queue.enqueue(clientEvent);
157
216
  };
158
217
 
@@ -166,10 +225,13 @@ export const createAnalytics = (
166
225
  }
167
226
  const clientEvent: ClientEvent = {
168
227
  event_type: "identify",
169
- session_id: sessionId,
228
+ // Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
229
+ // the session it already saw instead of back-filling a phantom `session_started`.
230
+ session_id: resolveSessionId(),
170
231
  user_id: clean,
171
232
  };
172
233
  if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
234
+ applyContext(clientEvent);
173
235
  queue.enqueue(clientEvent);
174
236
  };
175
237
 
@@ -177,6 +239,7 @@ export const createAnalytics = (
177
239
  track,
178
240
  screen,
179
241
  identify,
242
+ setUserContext,
180
243
  flush: queue.flush,
181
244
  notifyOnline: queue.notifyOnline,
182
245
  size: queue.size,
@@ -30,7 +30,7 @@ export type ContextEnvelope = {
30
30
  device: DeviceContext;
31
31
  /** Correlation id for this app-open / flow (caller-supplied). */
32
32
  sessionId?: string;
33
- /** Host app version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected). */
33
+ /** App version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected, else auto-detected). */
34
34
  appVersion?: string;
35
35
  /** Host native build number, e.g. "412" (from `expo-constants` `nativeBuildVersion`). */
36
36
  appBuild?: string;
@@ -47,9 +47,10 @@ export type ContextEnvelopeInput = {
47
47
  };
48
48
 
49
49
  /**
50
- * Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block and layers
51
- * the host-injected scalars on top. `appVersion` is additionally merged onto `device.appVersion`
52
- * when the device block lacks it (mirroring how `useSessionStart` back-fills the host version).
50
+ * Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block (which
51
+ * already carries a best-effort auto-detected `appVersion`) and layers the host-injected scalars
52
+ * on top. An explicit `input.appVersion` overrides the auto-detected `device.appVersion`, and the
53
+ * outer `appVersion` scalar mirrors whichever version is effective.
53
54
  *
54
55
  * Returns a NEW object on every call (no shared mutable reference), so a caller can hold or mutate
55
56
  * the result without leaking into the next envelope. Never throws — `collectDeviceContext` is
@@ -59,12 +60,17 @@ export const buildContextEnvelope = (input: ContextEnvelopeInput = {}): ContextE
59
60
  // Fresh copy so the returned envelope never aliases a cached device snapshot.
60
61
  const device: DeviceContext = { ...collectDeviceContext() };
61
62
 
62
- // Mirror useSessionStart: fill the host app version onto the device block when it lacks one.
63
- if (input.appVersion && !device.appVersion) device.appVersion = input.appVersion;
63
+ // `device.appVersion` is auto-detected best-effort by `collectDeviceContext`; an explicit
64
+ // host `input.appVersion` always wins.
65
+ if (input.appVersion) device.appVersion = input.appVersion;
66
+
67
+ // The outer scalar mirrors the effective version (host-supplied, else auto-detected) so the
68
+ // queue can stamp `user_context.app_version` even when the host never passed one.
69
+ const effectiveAppVersion = input.appVersion ?? device.appVersion;
64
70
 
65
71
  const envelope: ContextEnvelope = { device };
66
72
  if (input.sessionId) envelope.sessionId = input.sessionId;
67
- if (input.appVersion) envelope.appVersion = input.appVersion;
73
+ if (effectiveAppVersion) envelope.appVersion = effectiveAppVersion;
68
74
  if (input.appBuild) envelope.appBuild = input.appBuild;
69
75
  if (input.networkType) envelope.networkType = input.networkType;
70
76
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * currentSession — a tiny module-level registry of the CURRENT per-open `session_id`.
3
+ *
4
+ * WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
5
+ * `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
6
+ * post `app.session_started` with it — so the SERVER knows that id. But other client paths
7
+ * (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
8
+ * (a frozen per-instance id), which the server had never seen, so it back-filled a synthetic
9
+ * `session_started` — inflating session counts (the Morrow/Myelino "phantom-session" bug).
10
+ *
11
+ * This registry is the single seam that lets those paths reuse the LIVE per-open session id the
12
+ * server already ingested. `reportSessionStart` writes the current id here on every open; the façade
13
+ * reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
14
+ *
15
+ * DEPENDENCY-FREE + PROCESS-LOCAL: a plain module variable. It is intentionally NOT persisted — it
16
+ * tracks the CURRENT process's open, and a fresh open always overwrites it. No cross-launch state.
17
+ */
18
+
19
+ let _currentSessionId: string | undefined;
20
+
21
+ /**
22
+ * Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
23
+ * app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
24
+ */
25
+ export const setCurrentSessionId = (id: string | undefined): void => {
26
+ if (typeof id === "string" && id.length > 0) _currentSessionId = id;
27
+ };
28
+
29
+ /** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
30
+ export const getCurrentSessionId = (): string | undefined => _currentSessionId;
31
+
32
+ /** Test-only: forget the current session id so a unit test starts from a clean registry. */
33
+ export const resetCurrentSessionId = (): void => {
34
+ _currentSessionId = undefined;
35
+ };
@@ -57,3 +57,6 @@ export type {
57
57
 
58
58
  // ─── The thin optional React hook over the façade ─────────────────────────────
59
59
  export { useAnalytics } from "./useAnalytics";
60
+
61
+ // ─── Current per-open session registry (identify/app-events reuse the live session) ───
62
+ export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
@@ -0,0 +1,210 @@
1
+ /**
2
+ * userContext — the ONE extensible object a host passes once and the kit flows into every
3
+ * analytics event's `user_context` (plus the top-level opaque `user_id`).
4
+ *
5
+ * WHY it exists: hosts already hand the kit fragments of "who this user is" — `config.appVersion`,
6
+ * `useSessionStart({ deviceKey, userId })`, `<WireOnboarding userContext={…} />` — but there was no
7
+ * single object that carries app version + device key + user id + (opt-in) email + arbitrary extras
8
+ * together, with one precedence rule, into every event. `WireUserContext` is that object;
9
+ * `resolveUserContext` is the pure merge that turns it into the wire shape.
10
+ *
11
+ * PRECEDENCE (the one rule): an explicit `WireUserContext` field WINS over the #42 auto-detected
12
+ * `device`/`appVersion`. A missing field is OMITTED, never sent empty.
13
+ *
14
+ * WHERE EACH FIELD LANDS (deliberate separation so nothing leaks across buckets):
15
+ * • `userId` → the event's TOP-LEVEL opaque `user_id` (via `sanitizeUserId`). NEVER the bucket.
16
+ * • `userEmail` → its OWN key `user_context.user_email`. NEVER merged into `userId`. OPT-IN PII.
17
+ * • `deviceKey` → `user_context.device_key` (the server's `_event_device_key` reads it there).
18
+ * • `appVersion`→ `user_context.app_version` (and returned as `appVersion` for `device.appVersion`).
19
+ * • `extra` → NAMESPACED under a `custom.` key prefix, coerced to scalars, so a host extra can
20
+ * never collide with a reserved `user_context` key.
21
+ *
22
+ * DEPENDENCY-FREE: the only import is the kit's own `sanitizeUserId`. The optional email hash is a
23
+ * dependency-free FNV-1a fold (see {@link hashEmailFnv1a}) — no crypto library, no async.
24
+ */
25
+ import { sanitizeUserId } from "../identity/userIdentity";
26
+
27
+ /**
28
+ * The single, extensible user-context object. A host passes it ONCE (at analytics init) and may
29
+ * update it post-mount (e.g. attach `userId`/`userEmail` at login) via `setUserContext(partial)`.
30
+ * Every field is optional; missing fields are omitted from the wire payload.
31
+ */
32
+ export interface WireUserContext {
33
+ /**
34
+ * Host app version, e.g. "1.4.2". EXPLICIT — wins over the #42 auto-detected `device.appVersion`.
35
+ * Lands in `user_context.app_version`. Omitted when neither this nor auto-detect yields a version.
36
+ */
37
+ appVersion?: string;
38
+ /**
39
+ * A stable, non-PII device id the host owns. Lands in `user_context.device_key` (NOT `session_id`),
40
+ * where the server groups a device's sessions. Host-supplied; the kit never mints or reads one.
41
+ */
42
+ deviceKey?: string;
43
+ /**
44
+ * The host's OPAQUE PSEUDONYMOUS user id (their internal id — NOT an email/name/phone). Sanitized +
45
+ * capped (see `sanitizeUserId`) and placed on the event's top-level `user_id`. NEVER the bucket.
46
+ */
47
+ userId?: string;
48
+ /**
49
+ * OPT-IN PII. The user's email, its OWN field (`user_context.user_email`) — NEVER merged into
50
+ * `userId`. The kit NEVER auto-collects this; a host passes it only WITH the user's consent (EU
51
+ * users: treat as personal data). For a non-reversible form, set {@link hashEmail} `true` (the kit
52
+ * folds it with a dependency-free hash and stamps `user_context.user_email_hashed: true`), OR
53
+ * pre-hash host-side with a cryptographic digest and pass that here with `hashEmail` falsy.
54
+ */
55
+ userEmail?: string;
56
+ /**
57
+ * When `true`, {@link userEmail} is folded with the kit's dependency-free {@link hashEmailFnv1a}
58
+ * before it leaves the device, and `user_context.user_email_hashed` is set `true`. NOTE: FNV-1a is
59
+ * a lightweight NON-cryptographic fold (obfuscation, not a secure digest). For a cryptographic
60
+ * hash, compute it host-side (e.g. SHA-256 via `expo-crypto`) and pass the digest as `userEmail`
61
+ * with `hashEmail` falsy. Default: raw email is sent as-is (opt-in already gated it upstream).
62
+ */
63
+ hashEmail?: boolean;
64
+ /**
65
+ * Arbitrary host context (signup method, referral, plan tier…). Each value is coerced to a scalar
66
+ * (`string | number | boolean`; non-scalars and non-finite numbers are DROPPED) and NAMESPACED
67
+ * under a `custom.` key prefix in `user_context` (e.g. `user_context["custom.referral"]`) so it can
68
+ * never collide with a reserved key. No raw PII — use {@link userEmail} for email.
69
+ */
70
+ extra?: Record<string, string | number | boolean>;
71
+ }
72
+
73
+ /**
74
+ * The wire-shaped result of {@link resolveUserContext}. `userContext` is the non-PII/opt-in-PII
75
+ * bucket stamped onto the event; `userId` is the top-level opaque id; `appVersion`/`deviceKey` are
76
+ * echoed for callers that also place them elsewhere (e.g. `device.appVersion`). Absent fields are
77
+ * omitted so a caller can spread this without sending empties.
78
+ */
79
+ export interface ResolvedUserContext {
80
+ /** The opaque, sanitized user id → the event's top-level `user_id`. Omitted when unset/blank. */
81
+ userId?: string;
82
+ /** The stable device id → `user_context.device_key`. Omitted when unset. */
83
+ deviceKey?: string;
84
+ /** The effective app version (explicit > auto-detected) → `user_context.app_version`. */
85
+ appVersion?: string;
86
+ /** The `user_context` bucket (device_key, app_version, user_email[+ _hashed], custom.*). */
87
+ userContext?: Record<string, string | number | boolean>;
88
+ }
89
+
90
+ /** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
91
+ export const RESERVED_USER_CONTEXT_KEYS = [
92
+ "device_key",
93
+ "app_version",
94
+ "app_build",
95
+ "network_type",
96
+ "session_count",
97
+ "returning",
98
+ "platform",
99
+ "user_email",
100
+ "user_email_hashed",
101
+ ] as const;
102
+
103
+ /** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
104
+ export const EXTRA_KEY_PREFIX = "custom." as const;
105
+
106
+ /** A finite scalar the wire accepts. Non-finite numbers (NaN/Infinity) are NOT scalars here. */
107
+ export const isWireScalar = (value: unknown): value is string | number | boolean => {
108
+ const t = typeof value;
109
+ if (t === "string" || t === "boolean") return true;
110
+ if (t === "number") return Number.isFinite(value as number);
111
+ return false;
112
+ };
113
+
114
+ /**
115
+ * Fold an email to a stable, dependency-free 32-bit FNV-1a hex token (lowercased + trimmed first so
116
+ * the same address always folds identically). This is OBFUSCATION, not a cryptographic digest — it
117
+ * is not collision-resistant. For a real hash, pre-hash host-side and pass the digest as `userEmail`.
118
+ */
119
+ export const hashEmailFnv1a = (email: string): string => {
120
+ const normalized = email.trim().toLowerCase();
121
+ let hash = 0x811c9dc5; // FNV offset basis (32-bit)
122
+ for (let i = 0; i < normalized.length; i++) {
123
+ hash ^= normalized.charCodeAt(i);
124
+ hash = Math.imul(hash, 0x01000193); // FNV prime (32-bit), kept in 32-bit via imul
125
+ }
126
+ return (hash >>> 0).toString(16).padStart(8, "0");
127
+ };
128
+
129
+ /** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
130
+ const cleanString = (value: unknown): string | undefined => {
131
+ if (typeof value !== "string") return undefined;
132
+ const trimmed = value.trim();
133
+ return trimmed.length > 0 ? trimmed : undefined;
134
+ };
135
+
136
+ /**
137
+ * Coerce a host `extra` map into the namespaced, scalar-only bucket shape. Every kept value is
138
+ * placed under `custom.<key>`; non-scalar values (objects, arrays, null, functions, NaN/Infinity)
139
+ * are DROPPED. Returns an object (possibly empty).
140
+ */
141
+ export const namespaceExtra = (
142
+ extra: Record<string, unknown> | undefined,
143
+ ): Record<string, string | number | boolean> => {
144
+ const out: Record<string, string | number | boolean> = {};
145
+ if (!extra || typeof extra !== "object") return out;
146
+ for (const [key, value] of Object.entries(extra)) {
147
+ const cleanKey = cleanString(key);
148
+ if (!cleanKey) continue;
149
+ if (!isWireScalar(value)) continue; // drop anything that isn't a finite scalar
150
+ out[`${EXTRA_KEY_PREFIX}${cleanKey}`] = value;
151
+ }
152
+ return out;
153
+ };
154
+
155
+ /** Options for {@link resolveUserContext}. */
156
+ export interface ResolveUserContextOptions {
157
+ /**
158
+ * The kit's best-effort auto-detected app version (#42; from `detectAppVersion()`/the device
159
+ * snapshot). Used ONLY when the explicit `WireUserContext.appVersion` is absent — explicit wins.
160
+ */
161
+ autoAppVersion?: string;
162
+ }
163
+
164
+ /**
165
+ * Merge a {@link WireUserContext} into the wire shape with the precedence rule (explicit field >
166
+ * auto-detected). Pure, never throws. Missing fields are omitted so the result can be spread onto an
167
+ * event without sending empties.
168
+ */
169
+ export const resolveUserContext = (
170
+ ctx: WireUserContext = {},
171
+ opts: ResolveUserContextOptions = {},
172
+ ): ResolvedUserContext => {
173
+ const result: ResolvedUserContext = {};
174
+ const bucket: Record<string, string | number | boolean> = {};
175
+
176
+ // userId → top-level opaque id (NEVER the bucket). Sanitized + capped host-side.
177
+ const userId = sanitizeUserId(ctx.userId);
178
+ if (userId) result.userId = userId;
179
+
180
+ // deviceKey → user_context.device_key (NOT session_id).
181
+ const deviceKey = cleanString(ctx.deviceKey);
182
+ if (deviceKey) {
183
+ result.deviceKey = deviceKey;
184
+ bucket.device_key = deviceKey;
185
+ }
186
+
187
+ // appVersion → explicit wins over auto-detected (#42); echoed for device.appVersion callers.
188
+ const appVersion = cleanString(ctx.appVersion) ?? cleanString(opts.autoAppVersion);
189
+ if (appVersion) {
190
+ result.appVersion = appVersion;
191
+ bucket.app_version = appVersion;
192
+ }
193
+
194
+ // userEmail → its OWN key. OPT-IN PII, optionally folded. NEVER touches userId.
195
+ const email = cleanString(ctx.userEmail);
196
+ if (email) {
197
+ if (ctx.hashEmail) {
198
+ bucket.user_email = hashEmailFnv1a(email);
199
+ bucket.user_email_hashed = true;
200
+ } else {
201
+ bucket.user_email = email;
202
+ }
203
+ }
204
+
205
+ // extra → namespaced + scalar-coerced.
206
+ Object.assign(bucket, namespaceExtra(ctx.extra));
207
+
208
+ if (Object.keys(bucket).length > 0) result.userContext = bucket;
209
+ return result;
210
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * appVersion — best-effort, DEPENDENCY-FREE auto-detection of the host app's version string.
3
+ *
4
+ * WHY this exists: analytics segments the funnel `by_app_version`, but that breakdown is only
5
+ * populated when a `device.appVersion` rides the event. `config.appVersion` (see types.ts) has
6
+ * always been the way to supply it — but it is easy for a host to forget, and then the release
7
+ * breakdown is silently empty. This module fills that gap: when the host does NOT pass a version,
8
+ * the kit makes a best-effort read of the app version the host already ships in its Expo config,
9
+ * so the breakdown works out of the box. An explicit `config.appVersion` always WINS over this.
10
+ *
11
+ * WHY it adds NO dependency (the kit's hard rule): `expo-constants` / `expo-application` are read
12
+ * through a GUARDED, VARIABLE-specifier `require`. Passing a variable (not a string literal) keeps
13
+ * Metro/esbuild from statically resolving the module, so a host that does NOT have it installed
14
+ * (e.g. bare React Native) never fails to bundle — the require simply throws at runtime and is
15
+ * swallowed. Nothing is added to `package.json`; nothing is forced on the host.
16
+ *
17
+ * PRIVACY: an app version string is not PII and identifies no user or device, so surfacing it
18
+ * changes no App Privacy / Data Safety declaration (same guarantee as the rest of deviceContext).
19
+ *
20
+ * NEVER THROWS: every read is guarded; a missing/odd value yields `undefined`, never an exception.
21
+ * Analytics must never be able to break onboarding.
22
+ */
23
+
24
+ // Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime. Declared locally so
25
+ // this type-checks without ambient Node types; the `typeof` guard keeps the reference ESM-safe.
26
+ declare const require: ((id: string) => unknown) | undefined;
27
+
28
+ /** A `require`-like resolver. Injectable in tests; production uses the guarded runtime require. */
29
+ export type OptionalRequire = (moduleName: string) => unknown;
30
+
31
+ /** Trim + reject non-strings/empties so we only ever emit a real version string. */
32
+ export const coerceVersion = (value: unknown): string | undefined => {
33
+ if (typeof value !== "string") return undefined;
34
+ const trimmed = value.trim();
35
+ return trimmed.length > 0 ? trimmed : undefined;
36
+ };
37
+
38
+ /**
39
+ * Guarded runtime require. `moduleName` is a VARIABLE (a parameter), so bundlers cannot statically
40
+ * resolve it — a host without the module never fails to build; the call just throws and is caught.
41
+ */
42
+ const runtimeRequire: OptionalRequire = (moduleName) => {
43
+ try {
44
+ if (typeof require !== "function") return undefined;
45
+ return require(moduleName);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ };
50
+
51
+ /** Read a module's `default` (Expo modules are consumed as default exports) or the namespace. */
52
+ const interop = (mod: unknown): Record<string, unknown> | undefined => {
53
+ if (!mod || typeof mod !== "object") return undefined;
54
+ const def = (mod as { default?: unknown }).default;
55
+ if (def && typeof def === "object") return def as Record<string, unknown>;
56
+ return mod as Record<string, unknown>;
57
+ };
58
+
59
+ /** Resolve a module namespace, swallowing a throwing require (an uninstalled module throws). */
60
+ const safeInterop = (
61
+ requireModule: OptionalRequire,
62
+ moduleName: string,
63
+ ): Record<string, unknown> | undefined => {
64
+ try {
65
+ return interop(requireModule(moduleName));
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Detect the host app version, preferring `expo-constants` (`expoConfig.version`, then
73
+ * `nativeAppVersion`) and finally `expo-application` (`nativeApplicationVersion`). Returns the
74
+ * first real string, or `undefined` when none of those are available. Pure and never throws.
75
+ *
76
+ * `requireModule` is injectable so tests can exercise the "found" path without the native modules;
77
+ * production defaults to the guarded runtime require above.
78
+ */
79
+ export const detectAppVersion = (
80
+ requireModule: OptionalRequire = runtimeRequire,
81
+ ): string | undefined => {
82
+ try {
83
+ const constants = safeInterop(requireModule, "expo-constants");
84
+ if (constants) {
85
+ const expoConfig = constants.expoConfig;
86
+ if (expoConfig && typeof expoConfig === "object") {
87
+ const fromExpoConfig = coerceVersion((expoConfig as { version?: unknown }).version);
88
+ if (fromExpoConfig) return fromExpoConfig;
89
+ }
90
+ const fromNative = coerceVersion(constants.nativeAppVersion);
91
+ if (fromNative) return fromNative;
92
+ }
93
+
94
+ const application = safeInterop(requireModule, "expo-application");
95
+ if (application) {
96
+ const fromApplication = coerceVersion(application.nativeApplicationVersion);
97
+ if (fromApplication) return fromApplication;
98
+ }
99
+ } catch {
100
+ // Any unexpected read error → "unknown"; analytics must never crash onboarding.
101
+ }
102
+ return undefined;
103
+ };
@@ -4,9 +4,12 @@
4
4
  * adding a single dependency to the kit or changing a host app's App Privacy / Data Safety
5
5
  * declarations.
6
6
  *
7
- * HARD RULE (why this file has no imports beyond React Native built-ins):
7
+ * HARD RULE (why this file adds no dependency):
8
8
  * The kit stays dependency-free. Everything here comes from `Platform`, `Dimensions`,
9
- * `I18nManager`, and the standard `Intl` global. There are NO advertising IDs, NO
9
+ * `I18nManager`, and the standard `Intl` global plus a best-effort `appVersion` read via
10
+ * `detectAppVersion()`, which itself adds NO dependency (it reaches for `expo-constants` /
11
+ * `expo-application` through a guarded, variable-specifier require that a host without them
12
+ * simply never resolves — see device/appVersion.ts). There are NO advertising IDs, NO
10
13
  * `getUniqueId`/IDFA/GAID/fingerprinting APIs, and nothing that would require a new
11
14
  * privacy-label entry. A host can adopt this without touching its store declarations.
12
15
  *
@@ -17,6 +20,8 @@
17
20
  */
18
21
  import { Dimensions, I18nManager, Platform } from "react-native";
19
22
 
23
+ import { detectAppVersion } from "./appVersion";
24
+
20
25
  /** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
21
26
  export type DeviceFormFactor = "phone" | "tablet";
22
27
 
@@ -50,9 +55,11 @@ export type DeviceContext = {
50
55
  /** IANA time zone (e.g. "Europe/Berlin"), from `Intl` when available. */
51
56
  timeZone?: string;
52
57
  /**
53
- * Host app version (e.g. "1.4.2). HOST-INJECTED NOT collected here. `WireOnboarding`
54
- * merges `config.appVersion` into the snapshot; `collectDeviceContext()` never sets it.
55
- * Hosts typically pass it from `expo-constants` (the kit itself adds no dependency).
58
+ * Host app version (e.g. "1.4.2"). BEST-EFFORT auto-detected here via `detectAppVersion()`
59
+ * (reads `expo-constants` / `expo-application` when present; adds no dependency — see
60
+ * device/appVersion.ts). An explicit host-injected `config.appVersion` always WINS: the merge
61
+ * sites (`WireOnboarding`, the session-analytics hooks, the context envelope) overwrite this
62
+ * with the host value when one is supplied. Omitted when neither source yields a version.
56
63
  */
57
64
  appVersion?: string;
58
65
  };
@@ -154,5 +161,10 @@ export const collectDeviceContext = (): DeviceContext => {
154
161
  // Intl unavailable — omit locale/timeZone.
155
162
  }
156
163
 
164
+ // Best-effort host app version (adds no dependency; omitted when unavailable). An explicit
165
+ // `config.appVersion` overrides this downstream at the merge sites.
166
+ const appVersion = detectAppVersion();
167
+ if (appVersion) ctx.appVersion = appVersion;
168
+
157
169
  return ctx;
158
170
  };
@@ -20,6 +20,7 @@
20
20
  * ⚠️ NO PII. Pass an opaque id (or a hash), never a raw email/name/phone. The id is capped at
21
21
  * {@link USER_ID_MAX_LENGTH} chars (longer ids are truncated, not rejected).
22
22
  */
23
+ import { getCurrentSessionId } from "../analytics/currentSession";
23
24
  import { reportClientEvent } from "../analytics/reportClientEvent";
24
25
  import {
25
26
  peekPersistedSession,
@@ -90,6 +91,10 @@ export const identifyOnboarding = async (
90
91
  contextId = stored?.id;
91
92
  }
92
93
  }
94
+ // Last resort: bind to the LIVE per-open session (registered by `reportSessionStart`) so a
95
+ // post-flow identify with no captured contextId still attaches to a session the server saw,
96
+ // instead of no-oping. The onboarding contextId (above) is still preferred when available.
97
+ if (!contextId) contextId = getCurrentSessionId();
93
98
  if (!contextId) return false;
94
99
 
95
100
  reportClientEvent(