@wireai/activation 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -78,7 +78,7 @@ There is no `analytics` subpath: the app-event / analytics API is exported from
78
78
  | `serverUrl` | `string` | yes | Base server URL; the kit appends `/a2a`. |
79
79
  | `appId` | `string` | yes | Passed as the A2A `model` (informational; the key resolves the app). |
80
80
  | `metadata` | `Record<string, unknown>` | no | Merged into every A2A request (e.g. install attribution). The kit reserves `sessionId` + `supportedComponents`; do not override them. |
81
- | `appVersion` | `string` | no | Host app version string (e.g. `"1.4.2"`); forwarded to the backend + client events so analytics can segment the funnel by app version. |
81
+ | `appVersion` | `string` | no | App version string (e.g. `"1.4.2"`); forwarded to the backend + client events so analytics can segment the funnel by app version. Auto-detected best-effort from `expo-constants`/`expo-application` (dependency-free) when omitted; pass it to override (yours wins). |
82
82
 
83
83
  ## App events / analytics (root export)
84
84
 
package/CHANGELOG.md CHANGED
@@ -18,8 +18,100 @@ Historical entries below the rename keep the old package name on purpose.
18
18
  - Version reset to `0.1.0` to mark the start of the new package line. The GitHub repository
19
19
  stays `chohra-med/wireai-onboarding`.
20
20
 
21
+ ## [Unreleased]
22
+
23
+ ### Added: "device" is now fully automatic — auto-minted, persisted per-install `device_key`
24
+
25
+ - The analytics façade (`createAnalytics`) now auto-mints a stable, NON-PII per-install `device_key`
26
+ when the host supplies none via `WireUserContext.deviceKey`. It is minted ONCE (`context/deviceId.ts`
27
+ — `mintDeviceId`, dependency-free time+random, no `uuid`), PERSISTED via the host's existing
28
+ `storage` (`WireOnboardingStorage`) under `deviceIdStorageKey(appId)`, and REUSED on every open;
29
+ with no storage it falls back to an in-memory id. Result: `user_context.device_key` is ALWAYS
30
+ present, so the server's review/questionnaire gating + A/B stickiness (both key on `device_key`)
31
+ work out of the box with zero host wiring. A host-supplied `deviceKey` still WINS (and opts out of
32
+ auto-minting). New exports: `mintDeviceId`, `deviceIdStorageKey`, `AUTO_DEVICE_ID_PREFIX`.
33
+
34
+ ### Fixed: `WireUserContext.appVersion` now flows into `device.appVersion` (not just `user_context`)
35
+
36
+ - A host that set the app version ONLY inside `userContext` previously got it in
37
+ `user_context.app_version` but left `device.appVersion` on the auto-detected value — and the
38
+ server's `by_app_version` breakdown reads `device.appVersion`. The façade's context-envelope
39
+ provider now feeds `userContext.appVersion ?? config.appVersion` into `buildContextEnvelope`, so an
40
+ explicit `WireUserContext.appVersion` populates BOTH surfaces (explicit still wins over auto-detect).
41
+
42
+ ### Added: best-effort iOS `device.model`
43
+
44
+ - `device.model` was Android-only (`Platform.constants.Model`); iOS `Platform.constants` exposes no
45
+ model. `collectDeviceContext` now fills iOS `model` best-effort via `detectNativeModel`
46
+ (`device/deviceModel.ts`), reading `expo-device`'s `modelName`/`modelId` through the SAME guarded,
47
+ variable-specifier require the kit already uses for `appVersion` — so it adds NO dependency and is
48
+ simply omitted when `expo-device` is absent. It reads a device CLASS (not a unique id / not the
49
+ user-set device name), so it changes no App Privacy / Data Safety declaration. ASYMMETRY documented
50
+ in the `DeviceContext.model` TSDoc: without `expo-device`, iOS `model` stays omitted; no native
51
+ dependency is added for it.
52
+
53
+ - Backward compatible: no existing export renamed or removed; zero new dependencies. The one
54
+ behavior change is intentional — events now carry an auto `device_key` when the host omits one.
55
+
21
56
  ## [0.7.0] — Unreleased
22
57
 
58
+ ### Added: `WireUserContext` — one rich user-context object → every event's `user_context`
59
+
60
+ - New `context/userContext.ts` + public export `WireUserContext`: a single, extensible object a host
61
+ passes ONCE (`appVersion?`, `deviceKey?`, `userId?`, `userEmail?` + `hashEmail?`, `extra?`) that the
62
+ kit flows into every analytics event. Pure `resolveUserContext(ctx, { autoAppVersion })` merges it
63
+ with ONE precedence rule — an explicit field WINS over the auto-detected `device`/`appVersion` —
64
+ and omits missing fields (never sent empty). Also exported: `namespaceExtra`, `hashEmailFnv1a`,
65
+ `isWireScalar`, `RESERVED_USER_CONTEXT_KEYS`, `EXTRA_KEY_PREFIX`.
66
+ - Deliberate bucket separation (nothing leaks): `userId` → the event's TOP-LEVEL opaque `user_id`
67
+ (via `sanitizeUserId`, capped 128) and NEVER the `user_context` bucket; `deviceKey` →
68
+ `user_context.device_key` (NOT `session_id`); `appVersion` → `user_context.app_version`; `extra`
69
+ entries are coerced to `string | number | boolean` (non-scalars/NaN/Infinity dropped) and
70
+ NAMESPACED under a `custom.` key prefix so a host extra can never collide with a reserved key.
71
+ - **`userEmail` is OPT-IN PII in its OWN field** (`user_context.user_email`) — never merged into
72
+ `userId`. The kit NEVER auto-collects it; a host passes it only with the user's consent (EU users:
73
+ personal data). Set `hashEmail: true` to send a dependency-free FNV-1a fold instead of the raw
74
+ address (`user_context.user_email_hashed: true`). NOTE: FNV-1a is a lightweight non-cryptographic
75
+ fold; for a cryptographic digest, pre-hash host-side (e.g. `expo-crypto` SHA-256) and pass that as
76
+ `userEmail` with `hashEmail` falsy. Documented in the field's TSDoc.
77
+ - The analytics façade (`createAnalytics`) now accepts `userContext` at init AND exposes
78
+ `setUserContext(partial)` so a host can attach `userId`/`userEmail` at LOGIN without remounting;
79
+ both flow into subsequent `track`/`screen`/`identify` events' `user_context`. Backward compatible:
80
+ `createAnalytics({ serverUrl, apiKey })` with no `userContext` behaves exactly as before.
81
+
82
+ ### Fixed: `identify` + host app-events reuse the live per-open session (kills the phantom-session)
83
+
84
+ - New `analytics/currentSession.ts` (`getCurrentSessionId` / `setCurrentSessionId` /
85
+ `resetCurrentSessionId`): a process-local registry of the CURRENT per-open `session_id`.
86
+ `reportSessionStart` registers each per-open id here on every app-open.
87
+ - The façade's `identify` (and `track`/`screen` app-events) now source their `session_id` from
88
+ `config.sessionId ?? getCurrentSessionId() ?? <stable per-instance id>` — so identity binds to the
89
+ session the server ALREADY saw (via `app.session_started`) instead of minting a fresh id the server
90
+ back-fills into a synthetic session. This is what inflated the Morrow/Myelino session counts. An
91
+ explicit `config.sessionId` still freezes the id (opt-out). This is also the kit half of the #205
92
+ "align all app-events to the per-open session" follow-up. **No server change required** for the
93
+ normal flow (session-start fires on mount, identify at login); the only residual is an `identify`
94
+ fired before any open this launch, which falls back to the instance id.
95
+ - `identifyOnboarding` gains a last-resort fallback to `getCurrentSessionId()` when no `contextId`
96
+ is captured or resolvable from storage, so a post-flow identify binds to the live open session
97
+ instead of no-oping. An explicit `contextId` (or a stored one) still wins.
98
+
99
+ ### Added: `device.appVersion` auto-detection — populate the `by_app_version` breakdown by default
100
+
101
+ - `collectDeviceContext()` now carries a best-effort `appVersion` so the analytics
102
+ `by_app_version` funnel breakdown works out of the box, instead of staying empty until a host
103
+ remembers to pass `config.appVersion`. New `device/appVersion.ts` reads the version from
104
+ `expo-constants` (`expoConfig.version`, then `nativeAppVersion`) or `expo-application`
105
+ (`nativeApplicationVersion`) when present.
106
+ - ADDS NO DEPENDENCY: the Expo modules are read through a guarded, variable-specifier `require`,
107
+ so a host WITHOUT them (e.g. bare React Native) never fails to bundle — the read simply yields
108
+ `undefined`. Nothing is added to `package.json`; no peer is forced.
109
+ - An explicit host `config.appVersion` still WINS everywhere (the `WireOnboarding` device memo, the
110
+ `useSessionStart` / `useLifecycleEvents` hooks, and the analytics context envelope overwrite the
111
+ auto-detected value with the host's). The context envelope also mirrors the effective version
112
+ onto its outer `appVersion` scalar so `user_context.app_version` populates on facade events too.
113
+ - New public export `detectAppVersion()` (alongside `collectDeviceContext`). Never throws.
114
+
23
115
  ### Added: session mapping — know when a user opens the app again
24
116
 
25
117
  - New `session-analytics/` module. `reportSessionStart(opts)` posts ONE standard
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-BeO_Brcu.mjs';
2
- import { E as EventQueueOptions } from '../eventQueue-CA1d8Fmn.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, W as WIRE_ONBOARDING_EVENTS, g as WireOnboardingEventName, h as buildContextEnvelope, i as createEventQueue, m as makeSessionId, r as reportClientEvent, j as reportClientEvents, t as toAnalyticsEvent } from '../eventQueue-CA1d8Fmn.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-d9CrBxwe.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 reportClientEvents, n as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-d9CrBxwe.mjs';
4
4
  import '../types-A6pTxIZV.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-GL_hQ0TN.mjs';
@@ -120,6 +120,17 @@ type CreateAnalyticsConfig = {
120
120
  appBuild?: string;
121
121
  /** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
122
122
  networkType?: string;
123
+ /**
124
+ * The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
125
+ * user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
126
+ * {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional.
127
+ *
128
+ * NOTE on `deviceKey`: you do NOT need to supply one. When omitted, the kit auto-mints a stable,
129
+ * non-PII per-install `device_key`, persists it via `storage`, and reuses it every open (in-memory
130
+ * fallback without storage) — so `user_context.device_key` is ALWAYS present for the server's
131
+ * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
132
+ */
133
+ userContext?: WireUserContext;
123
134
  };
124
135
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
125
136
  type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
@@ -135,6 +146,12 @@ type Analytics = {
135
146
  screen(name: string, props?: AnalyticsProps): void;
136
147
  /** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
137
148
  identify(userId: string, traits?: AnalyticsProps): void;
149
+ /**
150
+ * Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
151
+ * merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
152
+ * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
153
+ */
154
+ setUserContext(partial: Partial<WireUserContext>): void;
138
155
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
139
156
  flush(): void;
140
157
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DLpd1v5_.js';
2
- import { E as EventQueueOptions } from '../eventQueue-CrNB9gzH.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, W as WIRE_ONBOARDING_EVENTS, g as WireOnboardingEventName, h as buildContextEnvelope, i as createEventQueue, m as makeSessionId, r as reportClientEvent, j as reportClientEvents, t as toAnalyticsEvent } from '../eventQueue-CrNB9gzH.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-f7LWcdWG.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 reportClientEvents, n as resetCurrentSessionId, s as setCurrentSessionId, t as toAnalyticsEvent } from '../currentSession-f7LWcdWG.js';
4
4
  import '../types-BhpXJGlg.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-GL_hQ0TN.js';
@@ -120,6 +120,17 @@ type CreateAnalyticsConfig = {
120
120
  appBuild?: string;
121
121
  /** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
122
122
  networkType?: string;
123
+ /**
124
+ * The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
125
+ * user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
126
+ * {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional.
127
+ *
128
+ * NOTE on `deviceKey`: you do NOT need to supply one. When omitted, the kit auto-mints a stable,
129
+ * non-PII per-install `device_key`, persists it via `storage`, and reuses it every open (in-memory
130
+ * fallback without storage) — so `user_context.device_key` is ALWAYS present for the server's
131
+ * review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
132
+ */
133
+ userContext?: WireUserContext;
123
134
  };
124
135
  /** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
125
136
  type AnalyticsOptions = Partial<Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">>;
@@ -135,6 +146,12 @@ type Analytics = {
135
146
  screen(name: string, props?: AnalyticsProps): void;
136
147
  /** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
137
148
  identify(userId: string, traits?: AnalyticsProps): void;
149
+ /**
150
+ * Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
151
+ * merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
152
+ * binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
153
+ */
154
+ setUserContext(partial: Partial<WireUserContext>): void;
138
155
  /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
139
156
  flush(): void;
140
157
  /** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
@@ -3,6 +3,13 @@
3
3
  var react = require('react');
4
4
  var reactNative = require('react-native');
5
5
 
6
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
7
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
8
+ }) : x)(function(x) {
9
+ if (typeof require !== "undefined") return require.apply(this, arguments);
10
+ throw Error('Dynamic require of "' + x + '" is not supported');
11
+ });
12
+
6
13
  // src/reviews/transport.ts
7
14
  var reportAppEvent = (target, name, options = {}) => {
8
15
  if (!(target == null ? void 0 : target.serverUrl) || !name) return;
@@ -157,6 +164,98 @@ var toAnalyticsEvent = (event) => {
157
164
  }
158
165
  }
159
166
  };
167
+
168
+ // src/device/appVersion.ts
169
+ var coerceVersion = (value) => {
170
+ if (typeof value !== "string") return void 0;
171
+ const trimmed = value.trim();
172
+ return trimmed.length > 0 ? trimmed : void 0;
173
+ };
174
+ var runtimeRequire = (moduleName) => {
175
+ try {
176
+ if (typeof __require !== "function") return void 0;
177
+ return __require(moduleName);
178
+ } catch {
179
+ return void 0;
180
+ }
181
+ };
182
+ var interop = (mod) => {
183
+ if (!mod || typeof mod !== "object") return void 0;
184
+ const def = mod.default;
185
+ if (def && typeof def === "object") return def;
186
+ return mod;
187
+ };
188
+ var safeInterop = (requireModule, moduleName) => {
189
+ try {
190
+ return interop(requireModule(moduleName));
191
+ } catch {
192
+ return void 0;
193
+ }
194
+ };
195
+ var detectAppVersion = (requireModule = runtimeRequire) => {
196
+ try {
197
+ const constants = safeInterop(requireModule, "expo-constants");
198
+ if (constants) {
199
+ const expoConfig = constants.expoConfig;
200
+ if (expoConfig && typeof expoConfig === "object") {
201
+ const fromExpoConfig = coerceVersion(expoConfig.version);
202
+ if (fromExpoConfig) return fromExpoConfig;
203
+ }
204
+ const fromNative = coerceVersion(constants.nativeAppVersion);
205
+ if (fromNative) return fromNative;
206
+ }
207
+ const application = safeInterop(requireModule, "expo-application");
208
+ if (application) {
209
+ const fromApplication = coerceVersion(application.nativeApplicationVersion);
210
+ if (fromApplication) return fromApplication;
211
+ }
212
+ } catch {
213
+ }
214
+ return void 0;
215
+ };
216
+
217
+ // src/device/deviceModel.ts
218
+ var coerceModel = (value) => {
219
+ if (typeof value !== "string") return void 0;
220
+ const trimmed = value.trim();
221
+ return trimmed.length > 0 ? trimmed : void 0;
222
+ };
223
+ var runtimeRequire2 = (moduleName) => {
224
+ try {
225
+ if (typeof __require !== "function") return void 0;
226
+ return __require(moduleName);
227
+ } catch {
228
+ return void 0;
229
+ }
230
+ };
231
+ var interop2 = (mod) => {
232
+ if (!mod || typeof mod !== "object") return void 0;
233
+ const def = mod.default;
234
+ if (def && typeof def === "object") return def;
235
+ return mod;
236
+ };
237
+ var safeInterop2 = (requireModule, moduleName) => {
238
+ try {
239
+ return interop2(requireModule(moduleName));
240
+ } catch {
241
+ return void 0;
242
+ }
243
+ };
244
+ var detectNativeModel = (requireModule = runtimeRequire2) => {
245
+ try {
246
+ const device = safeInterop2(requireModule, "expo-device");
247
+ if (device) {
248
+ const modelName = coerceModel(device.modelName);
249
+ if (modelName) return modelName;
250
+ const modelId = coerceModel(device.modelId);
251
+ if (modelId) return modelId;
252
+ }
253
+ } catch {
254
+ }
255
+ return void 0;
256
+ };
257
+
258
+ // src/device/deviceContext.ts
160
259
  var deriveFormFactor = (iosIdiom, width, height) => {
161
260
  if (iosIdiom === "pad") return "tablet";
162
261
  if (iosIdiom === "phone") return "phone";
@@ -202,6 +301,8 @@ var collectDeviceContext = () => {
202
301
  ctx.interfaceIdiom = idiom;
203
302
  iosIdiom = idiom;
204
303
  }
304
+ const iosModel = detectNativeModel();
305
+ if (iosModel) ctx.model = iosModel;
205
306
  }
206
307
  } catch {
207
308
  }
@@ -226,16 +327,20 @@ var collectDeviceContext = () => {
226
327
  if (resolved.timeZone) ctx.timeZone = resolved.timeZone;
227
328
  } catch {
228
329
  }
330
+ const appVersion = detectAppVersion();
331
+ if (appVersion) ctx.appVersion = appVersion;
229
332
  return ctx;
230
333
  };
231
334
 
232
335
  // src/analytics/contextEnvelope.ts
233
336
  var buildContextEnvelope = (input = {}) => {
337
+ var _a;
234
338
  const device = { ...collectDeviceContext() };
235
- if (input.appVersion && !device.appVersion) device.appVersion = input.appVersion;
339
+ if (input.appVersion) device.appVersion = input.appVersion;
340
+ const effectiveAppVersion = (_a = input.appVersion) != null ? _a : device.appVersion;
236
341
  const envelope = { device };
237
342
  if (input.sessionId) envelope.sessionId = input.sessionId;
238
- if (input.appVersion) envelope.appVersion = input.appVersion;
343
+ if (effectiveAppVersion) envelope.appVersion = effectiveAppVersion;
239
344
  if (input.appBuild) envelope.appBuild = input.appBuild;
240
345
  if (input.networkType) envelope.networkType = input.networkType;
241
346
  return envelope;
@@ -449,6 +554,16 @@ var createEventQueue = (options) => {
449
554
  return { enqueue, flush, notifyOnline, size };
450
555
  };
451
556
 
557
+ // src/analytics/currentSession.ts
558
+ var _currentSessionId;
559
+ var setCurrentSessionId = (id) => {
560
+ if (typeof id === "string" && id.length > 0) _currentSessionId = id;
561
+ };
562
+ var getCurrentSessionId = () => _currentSessionId;
563
+ var resetCurrentSessionId = () => {
564
+ _currentSessionId = void 0;
565
+ };
566
+
452
567
  // src/identity/userIdentity.ts
453
568
  var USER_ID_MAX_LENGTH = 128;
454
569
  var sanitizeUserId = (raw) => {
@@ -458,16 +573,109 @@ var sanitizeUserId = (raw) => {
458
573
  return trimmed.length > USER_ID_MAX_LENGTH ? trimmed.slice(0, USER_ID_MAX_LENGTH) : trimmed;
459
574
  };
460
575
 
576
+ // src/context/userContext.ts
577
+ var EXTRA_KEY_PREFIX = "custom.";
578
+ var isWireScalar = (value) => {
579
+ const t = typeof value;
580
+ if (t === "string" || t === "boolean") return true;
581
+ if (t === "number") return Number.isFinite(value);
582
+ return false;
583
+ };
584
+ var hashEmailFnv1a = (email) => {
585
+ const normalized = email.trim().toLowerCase();
586
+ let hash = 2166136261;
587
+ for (let i = 0; i < normalized.length; i++) {
588
+ hash ^= normalized.charCodeAt(i);
589
+ hash = Math.imul(hash, 16777619);
590
+ }
591
+ return (hash >>> 0).toString(16).padStart(8, "0");
592
+ };
593
+ var cleanString = (value) => {
594
+ if (typeof value !== "string") return void 0;
595
+ const trimmed = value.trim();
596
+ return trimmed.length > 0 ? trimmed : void 0;
597
+ };
598
+ var namespaceExtra = (extra) => {
599
+ const out = {};
600
+ if (!extra || typeof extra !== "object") return out;
601
+ for (const [key, value] of Object.entries(extra)) {
602
+ const cleanKey = cleanString(key);
603
+ if (!cleanKey) continue;
604
+ if (!isWireScalar(value)) continue;
605
+ out[`${EXTRA_KEY_PREFIX}${cleanKey}`] = value;
606
+ }
607
+ return out;
608
+ };
609
+ var resolveUserContext = (ctx = {}, opts = {}) => {
610
+ var _a;
611
+ const result = {};
612
+ const bucket = {};
613
+ const userId = sanitizeUserId(ctx.userId);
614
+ if (userId) result.userId = userId;
615
+ const deviceKey = cleanString(ctx.deviceKey);
616
+ if (deviceKey) {
617
+ result.deviceKey = deviceKey;
618
+ bucket.device_key = deviceKey;
619
+ }
620
+ const appVersion = (_a = cleanString(ctx.appVersion)) != null ? _a : cleanString(opts.autoAppVersion);
621
+ if (appVersion) {
622
+ result.appVersion = appVersion;
623
+ bucket.app_version = appVersion;
624
+ }
625
+ const email = cleanString(ctx.userEmail);
626
+ if (email) {
627
+ if (ctx.hashEmail) {
628
+ bucket.user_email = hashEmailFnv1a(email);
629
+ bucket.user_email_hashed = true;
630
+ } else {
631
+ bucket.user_email = email;
632
+ }
633
+ }
634
+ Object.assign(bucket, namespaceExtra(ctx.extra));
635
+ if (Object.keys(bucket).length > 0) result.userContext = bucket;
636
+ return result;
637
+ };
638
+
639
+ // src/context/deviceId.ts
640
+ var AUTO_DEVICE_ID_PREFIX = "wdev_";
641
+ var deviceIdStorageKey = (appId) => `wireai:analytics:deviceKey:${appId != null ? appId : "default"}`;
642
+ var randomChunk = () => Math.floor(Math.random() * 4294967296).toString(36).padStart(6, "0");
643
+ var mintDeviceId = () => {
644
+ const time = Date.now().toString(36);
645
+ return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
646
+ };
647
+
461
648
  // src/analytics/analyticsFacade.ts
462
649
  var createAnalytics = (config, options = {}) => {
463
- var _a, _b;
464
- const sessionId = (_a = config.sessionId) != null ? _a : makeSessionId();
465
- const envelope = () => buildContextEnvelope({
466
- sessionId,
467
- appVersion: config.appVersion,
468
- appBuild: config.appBuild,
469
- networkType: config.networkType
470
- });
650
+ var _a, _b, _c, _d, _e;
651
+ const instanceSessionId = (_a = config.sessionId) != null ? _a : makeSessionId();
652
+ const resolveSessionId = () => {
653
+ var _a2, _b2;
654
+ return (_b2 = (_a2 = config.sessionId) != null ? _a2 : getCurrentSessionId()) != null ? _b2 : instanceSessionId;
655
+ };
656
+ let userContext = { ...(_b = config.userContext) != null ? _b : {} };
657
+ const hostDeviceKeyAtInit = typeof ((_c = config.userContext) == null ? void 0 : _c.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
658
+ let autoDeviceKey = mintDeviceId();
659
+ if (config.storage && !hostDeviceKeyAtInit) {
660
+ const storage = config.storage;
661
+ const deviceKey = deviceIdStorageKey(config.appId);
662
+ void storage.getItem(deviceKey).then((saved) => {
663
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
664
+ if (persisted) autoDeviceKey = persisted;
665
+ else void storage.setItem(deviceKey, autoDeviceKey).catch(() => {
666
+ });
667
+ }).catch(() => {
668
+ });
669
+ }
670
+ const envelope = () => {
671
+ var _a2;
672
+ return buildContextEnvelope({
673
+ sessionId: resolveSessionId(),
674
+ appVersion: (_a2 = userContext.appVersion) != null ? _a2 : config.appVersion,
675
+ appBuild: config.appBuild,
676
+ networkType: config.networkType
677
+ });
678
+ };
471
679
  const queue = createEventQueue({
472
680
  target: { serverUrl: config.serverUrl, apiKey: config.apiKey },
473
681
  storage: config.storage,
@@ -475,23 +683,48 @@ var createAnalytics = (config, options = {}) => {
475
683
  envelope,
476
684
  ...options
477
685
  });
478
- let boundUserId;
479
- const storageKey = `wireai:analytics:userId:${(_b = config.appId) != null ? _b : "default"}`;
686
+ let boundUserId = sanitizeUserId((_d = config.userContext) == null ? void 0 : _d.userId);
687
+ const storageKey = `wireai:analytics:userId:${(_e = config.appId) != null ? _e : "default"}`;
480
688
  if (config.storage) {
481
689
  void config.storage.getItem(storageKey).then((saved) => {
482
- if (saved) boundUserId = saved;
690
+ if (saved && !boundUserId) boundUserId = saved;
483
691
  }).catch(() => {
484
692
  });
485
693
  }
694
+ const applyContext = (event) => {
695
+ var _a2;
696
+ const hostDeviceKey = typeof userContext.deviceKey === "string" && userContext.deviceKey.trim() ? userContext.deviceKey : void 0;
697
+ const resolved = resolveUserContext(
698
+ { ...userContext, deviceKey: hostDeviceKey != null ? hostDeviceKey : autoDeviceKey },
699
+ { autoAppVersion: config.appVersion }
700
+ );
701
+ if (resolved.userContext) {
702
+ event.user_context = { ...resolved.userContext, ...(_a2 = event.user_context) != null ? _a2 : {} };
703
+ }
704
+ if (boundUserId && !event.user_id) event.user_id = boundUserId;
705
+ };
706
+ const setUserContext = (partial) => {
707
+ var _a2, _b2;
708
+ if (!partial || typeof partial !== "object") return;
709
+ const mergedExtra = partial.extra || userContext.extra ? { ...(_a2 = userContext.extra) != null ? _a2 : {}, ...(_b2 = partial.extra) != null ? _b2 : {} } : void 0;
710
+ userContext = { ...userContext, ...partial };
711
+ if (mergedExtra) userContext.extra = mergedExtra;
712
+ const uid = sanitizeUserId(partial.userId);
713
+ if (uid) {
714
+ boundUserId = uid;
715
+ if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {
716
+ });
717
+ }
718
+ };
486
719
  const track = (event, props) => {
487
720
  if (!event) return;
488
721
  const clientEvent = {
489
722
  event_type: "app_event",
490
- session_id: sessionId,
723
+ session_id: resolveSessionId(),
491
724
  question_key: event
492
725
  };
493
726
  if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
494
- if (boundUserId) clientEvent.user_id = boundUserId;
727
+ applyContext(clientEvent);
495
728
  queue.enqueue(clientEvent);
496
729
  };
497
730
  const screen = (name, props) => {
@@ -499,11 +732,11 @@ var createAnalytics = (config, options = {}) => {
499
732
  const meta = { screen: name, ...props != null ? props : {} };
500
733
  const clientEvent = {
501
734
  event_type: "app_event",
502
- session_id: sessionId,
735
+ session_id: resolveSessionId(),
503
736
  question_key: "screen",
504
737
  meta: JSON.stringify(meta)
505
738
  };
506
- if (boundUserId) clientEvent.user_id = boundUserId;
739
+ applyContext(clientEvent);
507
740
  queue.enqueue(clientEvent);
508
741
  };
509
742
  const identify = (userId, traits) => {
@@ -516,16 +749,20 @@ var createAnalytics = (config, options = {}) => {
516
749
  }
517
750
  const clientEvent = {
518
751
  event_type: "identify",
519
- session_id: sessionId,
752
+ // Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
753
+ // the session it already saw instead of back-filling a phantom `session_started`.
754
+ session_id: resolveSessionId(),
520
755
  user_id: clean
521
756
  };
522
757
  if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
758
+ applyContext(clientEvent);
523
759
  queue.enqueue(clientEvent);
524
760
  };
525
761
  return {
526
762
  track,
527
763
  screen,
528
764
  identify,
765
+ setUserContext,
529
766
  flush: queue.flush,
530
767
  notifyOnline: queue.notifyOnline,
531
768
  size: queue.size
@@ -548,11 +785,14 @@ exports.createAnalytics = createAnalytics;
548
785
  exports.createEventQueue = createEventQueue;
549
786
  exports.createScreenTracker = createScreenTracker;
550
787
  exports.getActiveRouteName = getActiveRouteName;
788
+ exports.getCurrentSessionId = getCurrentSessionId;
551
789
  exports.makeSessionId = makeSessionId;
552
790
  exports.reportAppEvent = reportAppEvent;
553
791
  exports.reportClientEvent = reportClientEvent;
554
792
  exports.reportClientEvents = reportClientEvents;
793
+ exports.resetCurrentSessionId = resetCurrentSessionId;
555
794
  exports.screenTrackingHandler = screenTrackingHandler;
795
+ exports.setCurrentSessionId = setCurrentSessionId;
556
796
  exports.toAnalyticsEvent = toAnalyticsEvent;
557
797
  exports.useAnalytics = useAnalytics;
558
798
  exports.useScreenTracking = useScreenTracking;