@wireai/activation 0.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.1.1",
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>",
@@ -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
+ };
@@ -37,3 +37,11 @@ export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportCl
37
37
  // ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
38
38
  export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analyticsEvent";
39
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";
package/src/index.ts CHANGED
@@ -136,6 +136,23 @@ export type {
136
136
  UseSessionStartOptions,
137
137
  } from "./session-analytics";
138
138
 
139
+ // ─── Lifecycle events (top-of-funnel: `app.first_open` once-ever + session_start) ──
140
+ export {
141
+ reportFirstOpen,
142
+ wireLifecycleEvents,
143
+ resetFirstOpenLatch,
144
+ firstOpenStorageKey,
145
+ FIRST_OPEN_EVENT,
146
+ useLifecycleEvents,
147
+ } from "./session-analytics";
148
+ export type {
149
+ ReportFirstOpenOptions,
150
+ WireLifecycleOptions,
151
+ LifecycleEventInput,
152
+ LifecycleConfig,
153
+ UseLifecycleEventsOptions,
154
+ } from "./session-analytics";
155
+
139
156
  // ─── Session persistence (host-injected storage; see the `storage` prop) ──────
140
157
  export {
141
158
  loadPersistedSession,
@@ -16,3 +16,23 @@ export type { ReportSessionStartOptions } from "./reportSessionStart";
16
16
 
17
17
  export { useSessionStart, BACKGROUND_SESSION_MS } from "./useSessionStart";
18
18
  export type { SessionStartConfig, UseSessionStartOptions } from "./useSessionStart";
19
+
20
+ // ─── Top-of-funnel lifecycle events (first_open once-ever + session_start reused) ──
21
+ export {
22
+ reportFirstOpen,
23
+ wireLifecycleEvents,
24
+ resetFirstOpenLatch,
25
+ firstOpenStorageKey,
26
+ FIRST_OPEN_EVENT,
27
+ } from "./lifecycle";
28
+ export type {
29
+ ReportFirstOpenOptions,
30
+ WireLifecycleOptions,
31
+ LifecycleEventInput,
32
+ } from "./lifecycle";
33
+
34
+ export { useLifecycleEvents } from "./useLifecycleEvents";
35
+ export type {
36
+ LifecycleConfig,
37
+ UseLifecycleEventsOptions,
38
+ } from "./useLifecycleEvents";