@wireai/activation 0.1.0 → 0.2.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 (57) hide show
  1. package/AGENTS.md +82 -40
  2. package/CHANGELOG.md +17 -2
  3. package/INTEGRATION_PROMPT.md +8 -8
  4. package/README.md +38 -38
  5. package/dist/analytics/index.d.mts +92 -0
  6. package/dist/analytics/index.d.ts +92 -0
  7. package/dist/analytics/index.js +439 -0
  8. package/dist/analytics/index.js.map +1 -0
  9. package/dist/analytics/index.mjs +426 -0
  10. package/dist/analytics/index.mjs.map +1 -0
  11. package/dist/coachmarks/index.js.map +1 -1
  12. package/dist/coachmarks/index.mjs.map +1 -1
  13. package/dist/decision-BzbiKwk3.d.mts +79 -0
  14. package/dist/decision-plDEOCkt.d.ts +79 -0
  15. package/dist/eventQueue-CxKi7Qd5.d.mts +546 -0
  16. package/dist/eventQueue-rV1dtJJR.d.ts +546 -0
  17. package/dist/index.d.mts +162 -418
  18. package/dist/index.d.ts +162 -418
  19. package/dist/index.js +407 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +402 -5
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/questionnaire/index.d.mts +2 -1
  24. package/dist/questionnaire/index.d.ts +2 -1
  25. package/dist/questionnaire/index.js.map +1 -1
  26. package/dist/questionnaire/index.mjs.map +1 -1
  27. package/dist/reviews/index.d.mts +5 -40
  28. package/dist/reviews/index.d.ts +5 -40
  29. package/dist/reviews/index.js.map +1 -1
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/showcase/index.js.map +1 -1
  32. package/dist/showcase/index.mjs.map +1 -1
  33. package/dist/transport-BeO_Brcu.d.mts +40 -0
  34. package/dist/transport-DLpd1v5_.d.ts +40 -0
  35. package/dist/{decision-Cl8OFYzu.d.mts → types-A6pTxIZV.d.mts} +1 -77
  36. package/dist/{decision-CFvGY6nP.d.ts → types-BhpXJGlg.d.ts} +1 -77
  37. package/llms.txt +7 -7
  38. package/metro/index.d.ts +3 -3
  39. package/metro/index.js +3 -3
  40. package/package.json +15 -1
  41. package/src/analytics/contextEnvelope.ts +72 -0
  42. package/src/analytics/eventQueue.ts +331 -0
  43. package/src/analytics/index.ts +47 -0
  44. package/src/analytics/screenTracking.ts +122 -0
  45. package/src/analytics/useScreenTracking.ts +48 -0
  46. package/src/coachmarks/index.ts +2 -2
  47. package/src/features/WireFeaturesProvider.tsx +1 -1
  48. package/src/features/index.ts +2 -2
  49. package/src/index.ts +18 -1
  50. package/src/questionnaire/index.ts +2 -2
  51. package/src/reviews/index.ts +2 -2
  52. package/src/session-analytics/index.ts +20 -0
  53. package/src/session-analytics/lifecycle.ts +236 -0
  54. package/src/session-analytics/reportSessionStart.ts +27 -7
  55. package/src/session-analytics/useLifecycleEvents.ts +184 -0
  56. package/src/showcase/index.ts +2 -2
  57. package/src/types.ts +1 -1
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # wireai-onboarding
1
+ # @wireai/activation
2
2
 
3
3
  > A drop-in AI onboarding kit for React Native / Expo. One component, `<WireOnboarding>`, runs an
4
4
  > entire backend-orchestrated, AI-driven onboarding flow (themed cards, progress bar, a mid-flow
@@ -8,12 +8,12 @@
8
8
 
9
9
  ## What an agent needs to know
10
10
 
11
- - **Install:** `npm install wireai-onboarding wireai-rn` (react + react-native are peers).
12
- - **Metro (required):** `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from `wireai-onboarding/metro` pins one copy of react/react-native/wireai-rn/zod (prevents the dual-React crash).
11
+ - **Install:** `npm install @wireai/activation wireai-rn` (react + react-native are peers).
12
+ - **Metro (required):** `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from `@wireai/activation/metro`, which pins one copy of react/react-native/wireai-rn/zod (prevents the dual-React crash).
13
13
  - **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Created in the getwireai console or via the backend's `register_<app>.py`. Nothing renders without both.
14
14
  - **Render:** drop `<WireOnboarding config={wireConfigFromEnv({ appId })} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} />` into the signup flow.
15
- - **Capture is deterministic** `onComplete` returns `{ answers, raw }`; no second LLM call. Persist all answers through the app's normal profile-update path.
16
- - **It can never break onboarding** pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
15
+ - **Capture is deterministic.** `onComplete` returns `{ answers, raw }`; no second LLM call. Persist all answers through the app's normal profile-update path.
16
+ - **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
17
17
 
18
18
  ## Helpers (the reusable substrate)
19
19
 
@@ -21,7 +21,7 @@
21
21
  - `isOnboardingEnabled({ remote? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in.
22
22
  - `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)` → canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
23
23
  - `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
24
- - `reportClientEvent` / `makeSessionId` → report device-only funnel events to `POST /v1/events`. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
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
26
 
27
27
  ## Files
@@ -33,4 +33,4 @@
33
33
 
34
34
  ## Fastest path
35
35
 
36
- Run the `wire-rn-integration` Claude skill it detects the app's conventions, wires Metro, derives a theme, scaffolds the screen, wires a fallback, and type-checks. Otherwise use INTEGRATION_PROMPT.md.
36
+ Run the `wire-rn-integration` Claude skill. It detects the app's conventions, wires Metro, derives a theme, scaffolds the screen, wires a fallback, and type-checks. Otherwise use INTEGRATION_PROMPT.md.
package/metro/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Type declarations for the `wireai-onboarding/metro` helper. This module runs in
2
+ * Type declarations for the `@wireai/activation/metro` helper. This module runs in
3
3
  * Node inside metro.config.js, so the config is loosely typed (`any`-ish) to avoid
4
4
  * coupling to a specific Metro/Expo version.
5
5
  */
@@ -11,7 +11,7 @@ export interface WithWireOnboardingOptions {
11
11
  */
12
12
  appRoot?: string;
13
13
  /**
14
- * Absolute path to the kit's `src/` dir. When set, the `wireai-onboarding`
14
+ * Absolute path to the kit's `src/` dir. When set, the `@wireai/activation`
15
15
  * specifier maps here (source consumption) and the dir is added to watchFolders.
16
16
  * When omitted, the kit resolves from the installed package.
17
17
  */
@@ -19,7 +19,7 @@ export interface WithWireOnboardingOptions {
19
19
  }
20
20
 
21
21
  /**
22
- * Non-destructively wire the wireai-onboarding kit into an existing Metro config:
22
+ * Non-destructively wire the @wireai/activation kit into an existing Metro config:
23
23
  * pins single-instance deps to the app's copy, optionally maps the kit specifier
24
24
  * to source + watches it. Mutates and returns the same config object.
25
25
  */
package/metro/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * withWireOnboarding — one-call Metro wiring for the wireai-onboarding kit.
2
+ * withWireOnboarding — one-call Metro wiring for the @wireai/activation kit.
3
3
  *
4
4
  * This file runs in Node inside your app's `metro.config.js` (it is NOT React
5
5
  * Native code), so it's plain CommonJS with zero RN imports.
@@ -10,13 +10,13 @@
10
10
  *
11
11
  * // External app (kit installed from git/tarball):
12
12
  * const { getDefaultConfig } = require('expo/metro-config');
13
- * const { withWireOnboarding } = require('wireai-onboarding/metro');
13
+ * const { withWireOnboarding } = require('@wireai/activation/metro');
14
14
  * module.exports = withWireOnboarding(getDefaultConfig(__dirname));
15
15
  *
16
16
  * // Source consumer (monorepo app, kit resolved from its src/):
17
17
  * const path = require('path');
18
18
  * module.exports = withWireOnboarding(getDefaultConfig(__dirname), {
19
- * source: path.join(__dirname, '../../getwire_ai/wireai-onboarding/src'),
19
+ * source: path.join(__dirname, '../../wire-activation/src'),
20
20
  * });
21
21
  *
22
22
  * @param {object} config An existing Metro config object (e.g. from getDefaultConfig).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.1.0",
3
+ "version": "0.2.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>",
@@ -46,6 +46,20 @@
46
46
  "default": "./dist/index.js"
47
47
  }
48
48
  },
49
+ "./analytics": {
50
+ "react-native": {
51
+ "types": "./dist/analytics/index.d.ts",
52
+ "default": "./src/analytics/index.ts"
53
+ },
54
+ "import": {
55
+ "types": "./dist/analytics/index.d.mts",
56
+ "default": "./dist/analytics/index.mjs"
57
+ },
58
+ "require": {
59
+ "types": "./dist/analytics/index.d.ts",
60
+ "default": "./dist/analytics/index.js"
61
+ }
62
+ },
49
63
  "./coachmarks": {
50
64
  "react-native": {
51
65
  "types": "./dist/coachmarks/index.d.ts",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * contextEnvelope — a small, PRIVACY-NEUTRAL context bundle stamped onto every outgoing
3
+ * analytics event, giving the Wire dashboard the Sentry/Firebase-parity segmentation fields
4
+ * (device model, OS + version, screen, locale, timezone, form factor) plus a few host-injected
5
+ * scalars (session correlation id, app version + native build number, connectivity type).
6
+ *
7
+ * WHY a separate builder (not just `collectDeviceContext`): the envelope COMPOSES the existing
8
+ * device snapshot with the handful of extras a host can cheaply supply but the kit can't collect
9
+ * dependency-free (native build number, connectivity type). It never re-implements device
10
+ * collection — it reuses `collectDeviceContext()` verbatim (see device/deviceContext.ts).
11
+ *
12
+ * HARD PRIVACY RULE (why this file, like deviceContext.ts, adds nothing new):
13
+ * NEVER GPS / location, NEVER an advertising id (IDFA / GAID), NEVER a device fingerprint.
14
+ * Location is derived SERVER-SIDE from IP-geo only — nothing here carries a coordinate or an
15
+ * ad id, so a host adopting this changes no App Privacy / Data Safety declaration. There is a
16
+ * test (contextEnvelope.test.ts) that asserts the ABSENCE of any such field.
17
+ *
18
+ * DEPENDENCY-FREE: the only import is the kit's own `collectDeviceContext`. `networkType` and
19
+ * `appBuild` are HOST-INJECTED — there is no dependency-free RN core signal for either, so the
20
+ * envelope simply omits them when the host does not pass them (no forced peer dependency).
21
+ */
22
+ import { collectDeviceContext, type DeviceContext } from "../device/deviceContext";
23
+
24
+ /**
25
+ * The context stamped onto every event. `device` is always present (from
26
+ * `collectDeviceContext`); every scalar is optional and OMITTED when the host does not supply it.
27
+ */
28
+ export type ContextEnvelope = {
29
+ /** The privacy-neutral device snapshot (reused from `collectDeviceContext`). */
30
+ device: DeviceContext;
31
+ /** Correlation id for this app-open / flow (caller-supplied). */
32
+ sessionId?: string;
33
+ /** Host app version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected). */
34
+ appVersion?: string;
35
+ /** Host native build number, e.g. "412" (from `expo-constants` `nativeBuildVersion`). */
36
+ appBuild?: string;
37
+ /** Host connectivity signal, e.g. "wifi" | "cellular" (from `@react-native-community/netinfo`). */
38
+ networkType?: string;
39
+ };
40
+
41
+ /** Host-injected inputs for {@link buildContextEnvelope}. All optional; each is omitted when absent. */
42
+ export type ContextEnvelopeInput = {
43
+ sessionId?: string;
44
+ appVersion?: string;
45
+ appBuild?: string;
46
+ networkType?: string;
47
+ };
48
+
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).
53
+ *
54
+ * Returns a NEW object on every call (no shared mutable reference), so a caller can hold or mutate
55
+ * the result without leaking into the next envelope. Never throws — `collectDeviceContext` is
56
+ * itself guarded, and the rest is plain assignment.
57
+ */
58
+ export const buildContextEnvelope = (input: ContextEnvelopeInput = {}): ContextEnvelope => {
59
+ // Fresh copy so the returned envelope never aliases a cached device snapshot.
60
+ const device: DeviceContext = { ...collectDeviceContext() };
61
+
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;
64
+
65
+ const envelope: ContextEnvelope = { device };
66
+ if (input.sessionId) envelope.sessionId = input.sessionId;
67
+ if (input.appVersion) envelope.appVersion = input.appVersion;
68
+ if (input.appBuild) envelope.appBuild = input.appBuild;
69
+ if (input.networkType) envelope.networkType = input.networkType;
70
+
71
+ return envelope;
72
+ };
@@ -0,0 +1,331 @@
1
+ /**
2
+ * eventQueue — the OFFLINE-FIRST, persistent transport buffer for client analytics events.
3
+ *
4
+ * The existing `reportClientEvents` is a blind fire-and-forget POST: it returns `void`, has no
5
+ * success signal, and drops events when the network is down. The analytics data story depends on
6
+ * NEVER losing an event offline, so this queue adds the missing durability layer on top of the
7
+ * same `POST {serverUrl}/v1/events` contract:
8
+ *
9
+ * • Persists pending events to the host-injected `WireOnboardingStorage` (survives app kills).
10
+ * • Batches them into one request body `{ events: [...] }`.
11
+ * • Owns its OWN awaited `fetch` that reads `res.ok` — the only way to drive retry + dequeue,
12
+ * since `reportClientEvents` cannot ack. A 2xx dequeues the batch; a non-ok / rejected / thrown
13
+ * response keeps it and schedules an exponential backoff retry.
14
+ * • Flushes on `enqueue`, on an explicit `flush()`, and on `notifyOnline()` (host reconnect).
15
+ * • Caps the buffer (drop-OLDEST under pressure) and de-dups identical pending events.
16
+ * • Stamps the current context envelope (device + host scalars) onto every event before send.
17
+ *
18
+ * FIRE-AND-FORGET (load-bearing): `enqueue` returns immediately and NEVER throws into the UI. A
19
+ * missing `fetch`, a hung/broken storage, a rejecting network, or a JSON error is swallowed and
20
+ * degrades gracefully — analytics must never be able to break the app. In-memory fallback covers
21
+ * the no-storage case (survives re-renders, not app kills).
22
+ *
23
+ * DEPENDENCY-FREE: no network-detection or persistence library. Connectivity is host-driven via
24
+ * `notifyOnline()`; persistence is the host-injected AsyncStorage-compatible subset.
25
+ */
26
+ import type { ContextEnvelope } from "./contextEnvelope";
27
+ import type { ClientEvent, ClientEventTarget } from "./reportClientEvent";
28
+ import type { WireOnboardingStorage } from "../session/persistedSession";
29
+
30
+ /** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
31
+ export type EnvelopeSource = ContextEnvelope | (() => ContextEnvelope | undefined);
32
+
33
+ /** Options for {@link createEventQueue}. Only `target` is conceptually required to actually send. */
34
+ export type EventQueueOptions = {
35
+ /** Where to POST — the tenant transport (`serverUrl` + `apiKey`), same as `WireOnboardingConfig`. */
36
+ target: ClientEventTarget | undefined;
37
+ /**
38
+ * Host persistence (AsyncStorage-compatible subset). When omitted, the queue runs in the
39
+ * documented DEGRADED in-memory mode — it survives re-renders but not an app kill.
40
+ */
41
+ storage?: WireOnboardingStorage;
42
+ /** Tenant/app id used to namespace the default storage key (`wireai:evtq:<appId>`). */
43
+ appId?: string;
44
+ /** Explicit storage key override (wins over the `appId`-derived default). */
45
+ storageKey?: string;
46
+ /** The context envelope stamped onto every event before send (device + host scalars). */
47
+ envelope?: EnvelopeSource;
48
+ /** Max pending events; enqueuing past this DROPS THE OLDEST first (default 200). */
49
+ maxSize?: number;
50
+ /** Events per POST batch (default 20). */
51
+ batchSize?: number;
52
+ /** First retry delay in ms; doubles each failed attempt (default 1000). */
53
+ baseBackoffMs?: number;
54
+ /** Backoff ceiling in ms (default 30000). */
55
+ maxBackoffMs?: number;
56
+ /** Max AUTOMATIC backoff retries before pausing (default 6); `notifyOnline()`/`flush()` re-arm it. */
57
+ maxRetries?: number;
58
+ };
59
+
60
+ /** The queue's public surface. `enqueue` is fire-and-forget (returns immediately, never throws). */
61
+ export type EventQueue = {
62
+ /** Buffer one event (envelope-stamped), persist, and schedule a flush. Never throws. */
63
+ enqueue(event: ClientEvent): void;
64
+ /** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
65
+ flush(): void;
66
+ /** Host reconnect signal: reset backoff and drain immediately. Fire-and-forget. */
67
+ notifyOnline(): void;
68
+ /** Current pending (in-memory) count. */
69
+ size(): number;
70
+ };
71
+
72
+ const DEFAULTS = {
73
+ maxSize: 200,
74
+ batchSize: 20,
75
+ baseBackoffMs: 1000,
76
+ maxBackoffMs: 30000,
77
+ maxRetries: 6,
78
+ } as const;
79
+
80
+ /** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
81
+ const READ_TIMEOUT_MS = 1500;
82
+
83
+ /** Internal buffered item. `id` is a local monotonic handle for deterministic dequeue-after-ack;
84
+ * it is NEVER sent to the server. `sig` is the de-dup signature (serialized stamped event). */
85
+ type QueuedItem = { id: number; event: ClientEvent; sig: string };
86
+
87
+ /** Persisted shape — the local id + the (already envelope-stamped) event. `sig` is recomputed on load. */
88
+ type PersistedItem = { id: number; event: ClientEvent };
89
+
90
+ const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
91
+ let timer: ReturnType<typeof setTimeout>;
92
+ const timeout = new Promise<undefined>((resolve) => {
93
+ timer = setTimeout(() => resolve(undefined), ms);
94
+ });
95
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
96
+ };
97
+
98
+ /** Detach a timer from the event loop where the runtime supports it (Node test process / some RNs). */
99
+ const unrefTimer = (timer: ReturnType<typeof setTimeout>): void => {
100
+ const t = timer as unknown as { unref?: () => void };
101
+ if (typeof t.unref === "function") t.unref();
102
+ };
103
+
104
+ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
105
+ if (!raw) return [];
106
+ try {
107
+ const parsed: unknown = JSON.parse(raw);
108
+ if (!Array.isArray(parsed)) return [];
109
+ const items: PersistedItem[] = [];
110
+ for (const entry of parsed) {
111
+ if (
112
+ entry &&
113
+ typeof entry === "object" &&
114
+ typeof (entry as PersistedItem).id === "number" &&
115
+ (entry as PersistedItem).event &&
116
+ typeof (entry as PersistedItem).event === "object"
117
+ ) {
118
+ items.push(entry as PersistedItem);
119
+ }
120
+ }
121
+ return items;
122
+ } catch {
123
+ // Corrupt backlog → start empty; the next persist overwrites it.
124
+ return [];
125
+ }
126
+ };
127
+
128
+ /**
129
+ * Create an offline-first event queue. Loads any persisted backlog on creation so a
130
+ * killed-and-relaunched app resumes where it left off. Returns the {@link EventQueue} surface.
131
+ */
132
+ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
133
+ const target = options.target;
134
+ const storage = options.storage;
135
+ const key = options.storageKey ?? `wireai:evtq:${options.appId ?? "default"}`;
136
+ const maxSize = options.maxSize ?? DEFAULTS.maxSize;
137
+ const batchSize = options.batchSize ?? DEFAULTS.batchSize;
138
+ const baseBackoffMs = options.baseBackoffMs ?? DEFAULTS.baseBackoffMs;
139
+ const maxBackoffMs = options.maxBackoffMs ?? DEFAULTS.maxBackoffMs;
140
+ const maxRetries = options.maxRetries ?? DEFAULTS.maxRetries;
141
+
142
+ let pending: QueuedItem[] = [];
143
+ let nextId = 0;
144
+ let flushing = false;
145
+ let attempt = 0;
146
+ let retryTimer: ReturnType<typeof setTimeout> | undefined;
147
+
148
+ const resolveEnvelope = (): ContextEnvelope | undefined => {
149
+ try {
150
+ return typeof options.envelope === "function" ? options.envelope() : options.envelope;
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ };
155
+
156
+ // Stamp the current envelope onto a COPY of the event (never mutate the caller's object):
157
+ // device → event.device (when the event has none)
158
+ // sessionId → event.session_id (when the event has none)
159
+ // appVersion / appBuild / networkType → event.user_context (the non-PII bucket the server
160
+ // sanitizes), never overwriting a key the caller already set.
161
+ const stamp = (event: ClientEvent): ClientEvent => {
162
+ const env = resolveEnvelope();
163
+ const stamped: ClientEvent = { ...event };
164
+ if (!env) return stamped;
165
+ if (!stamped.device && env.device) stamped.device = env.device;
166
+ if (!stamped.session_id && env.sessionId) stamped.session_id = env.sessionId;
167
+ const uc: Record<string, string | number | boolean> = { ...(stamped.user_context ?? {}) };
168
+ if (env.appVersion && uc.app_version === undefined) uc.app_version = env.appVersion;
169
+ if (env.appBuild && uc.app_build === undefined) uc.app_build = env.appBuild;
170
+ if (env.networkType && uc.network_type === undefined) uc.network_type = env.networkType;
171
+ if (Object.keys(uc).length > 0) stamped.user_context = uc;
172
+ return stamped;
173
+ };
174
+
175
+ const persist = (): void => {
176
+ if (!storage) return;
177
+ try {
178
+ if (pending.length === 0) {
179
+ void storage.removeItem(key).catch(() => {});
180
+ return;
181
+ }
182
+ const payload: PersistedItem[] = pending.map((item) => ({ id: item.id, event: item.event }));
183
+ void storage.setItem(key, JSON.stringify(payload)).catch(() => {});
184
+ } catch {
185
+ // Best-effort: a failed write just means the backlog is not durable this launch.
186
+ }
187
+ };
188
+
189
+ const enforceSizeCap = (): void => {
190
+ // Drop the OLDEST first so the newest events are never the ones lost under pressure.
191
+ if (pending.length > maxSize) pending.splice(0, pending.length - maxSize);
192
+ };
193
+
194
+ const safeSig = (event: ClientEvent): string => {
195
+ try {
196
+ return JSON.stringify(event);
197
+ } catch {
198
+ // Non-serializable event → give it a unique signature so it is never wrongly de-duped.
199
+ return `__nosig_${nextId}_${Math.random()}`;
200
+ }
201
+ };
202
+
203
+ // Load any persisted backlog. Anything enqueued before this settles stays in memory; we merge
204
+ // persisted (older) ahead of it and reassign monotonic ids so dequeue-after-ack is deterministic.
205
+ const loadPromise: Promise<void> = (async () => {
206
+ if (!storage) return;
207
+ try {
208
+ const persistedItems = parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
209
+ if (persistedItems.length === 0) return;
210
+ const events = [...persistedItems.map((p) => p.event), ...pending.map((p) => p.event)];
211
+ pending = [];
212
+ nextId = 0;
213
+ const seen = new Set<string>();
214
+ for (const event of events) {
215
+ const sig = safeSig(event);
216
+ if (seen.has(sig)) continue; // collapse duplicates carried across the merge
217
+ seen.add(sig);
218
+ pending.push({ id: nextId++, event, sig });
219
+ }
220
+ enforceSizeCap();
221
+ persist();
222
+ } catch {
223
+ // Unreadable backlog → start empty; nothing enqueued in-memory is lost.
224
+ }
225
+ })();
226
+
227
+ // The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
228
+ // fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
229
+ const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
230
+ if (!target?.serverUrl || events.length === 0) return false;
231
+ try {
232
+ const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
233
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
234
+ if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
235
+ const res = await fetch(url, {
236
+ method: "POST",
237
+ headers,
238
+ body: JSON.stringify({ events }),
239
+ });
240
+ return !!(res && (res as { ok?: boolean }).ok);
241
+ } catch {
242
+ return false;
243
+ }
244
+ };
245
+
246
+ const clearRetry = (): void => {
247
+ if (retryTimer !== undefined) {
248
+ clearTimeout(retryTimer);
249
+ retryTimer = undefined;
250
+ }
251
+ };
252
+
253
+ const scheduleRetry = (): void => {
254
+ // Bounded automatic retry. Past the cap the backlog simply waits for the next
255
+ // `notifyOnline()` / `flush()` (both re-arm attempt), so events are paused, never dropped.
256
+ if (attempt >= maxRetries) return;
257
+ const delay = Math.min(baseBackoffMs * 2 ** attempt, maxBackoffMs);
258
+ attempt++;
259
+ clearRetry();
260
+ retryTimer = setTimeout(() => {
261
+ retryTimer = undefined;
262
+ void drain();
263
+ }, delay);
264
+ unrefTimer(retryTimer);
265
+ };
266
+
267
+ const drain = async (): Promise<void> => {
268
+ try {
269
+ await loadPromise;
270
+ } catch {
271
+ // load already swallows; guard the await defensively.
272
+ }
273
+ if (flushing) return;
274
+ flushing = true;
275
+ try {
276
+ while (pending.length > 0) {
277
+ const batch = pending.slice(0, batchSize);
278
+ const ok = await postBatch(batch.map((item) => item.event));
279
+ if (!ok) {
280
+ scheduleRetry();
281
+ return;
282
+ }
283
+ // Dequeue exactly the acked batch by id (pending may have grown while in flight).
284
+ const acked = new Set(batch.map((item) => item.id));
285
+ pending = pending.filter((item) => !acked.has(item.id));
286
+ persist();
287
+ attempt = 0;
288
+ clearRetry();
289
+ }
290
+ } finally {
291
+ flushing = false;
292
+ }
293
+ };
294
+
295
+ const flush = (): void => {
296
+ try {
297
+ void drain();
298
+ } catch {
299
+ // drain never throws synchronously, but guard the kick anyway.
300
+ }
301
+ };
302
+
303
+ const enqueue = (event: ClientEvent): void => {
304
+ try {
305
+ const stamped = stamp(event);
306
+ const sig = safeSig(stamped);
307
+ // Collapse a redundant re-enqueue of an identical pending event.
308
+ for (const item of pending) {
309
+ if (item.sig === sig) return;
310
+ }
311
+ pending.push({ id: nextId++, event: stamped, sig });
312
+ enforceSizeCap();
313
+ persist();
314
+ // Only kick a drain when no retry is already pending — avoids hammering fetch while offline.
315
+ if (retryTimer === undefined) flush();
316
+ } catch {
317
+ // Fire-and-forget: nothing in enqueue may surface to the UI.
318
+ }
319
+ };
320
+
321
+ const notifyOnline = (): void => {
322
+ // Host reconnected: reset the backoff and drain now.
323
+ attempt = 0;
324
+ clearRetry();
325
+ flush();
326
+ };
327
+
328
+ const size = (): number => pending.length;
329
+
330
+ return { enqueue, flush, notifyOnline, size };
331
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @wireai/activation/analytics — the coherent analytics entry.
3
+ *
4
+ * Subpath export kept OUT of the main barrel so an analytics-only consumer never pulls in the
5
+ * onboarding / showcase / review UI (the kit's tree-shaking guarantee). It bundles the OPT-IN
6
+ * automatic screen-tracking helper with the pre-existing analytics primitives so a host has one
7
+ * import for "report app + onboarding events":
8
+ *
9
+ * import { createScreenTracker, screenTrackingHandler, reportAppEvent } from "@wireai/activation/analytics";
10
+ *
11
+ * The screen-tracking core imports NO navigation library and NO React (the React glue is the thin
12
+ * `useScreenTracking` hook); `reportAppEvent` is pulled from `../reviews/transport` directly, not
13
+ * the `../reviews` barrel, to keep the review UI out of an analytics-only bundle.
14
+ */
15
+
16
+ // ─── Automatic screen tracking (dependency-free core) ─────────────────────────
17
+ export { createScreenTracker, getActiveRouteName, screenTrackingHandler } from "./screenTracking";
18
+ export type {
19
+ ScreenTracker,
20
+ ScreenTrackerOptions,
21
+ NavigationStateLike,
22
+ NavigationRouteLike,
23
+ } from "./screenTracking";
24
+
25
+ // ─── The thin optional React hook over the core ───────────────────────────────
26
+ export { useScreenTracking } from "./useScreenTracking";
27
+ export type { NavigationRefLike } from "./useScreenTracking";
28
+
29
+ // ─── The app-event transport this helper feeds (pure fetch, no review UI) ─────
30
+ export { reportAppEvent } from "../reviews/transport";
31
+ export type { ReportAppEventOptions } from "../reviews/transport";
32
+
33
+ // ─── Device-only onboarding event reporters + session id seed ─────────────────
34
+ export { reportClientEvent, reportClientEvents, makeSessionId } from "./reportClientEvent";
35
+ export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportClientEvent";
36
+
37
+ // ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
38
+ export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analyticsEvent";
39
+ export type { WireOnboardingEventName, AnalyticsEvent } from "./analyticsEvent";
40
+
41
+ // ─── Non-PII context envelope (device + host scalars) stamped onto every event ─
42
+ export { buildContextEnvelope } from "./contextEnvelope";
43
+ export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
44
+
45
+ // ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
46
+ export { createEventQueue } from "./eventQueue";
47
+ export type { EventQueue, EventQueueOptions, EnvelopeSource } from "./eventQueue";