@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/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +289 -0
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -1
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{analyticsEvent-B8v3BZjM.d.mts → eventQueue-CxKi7Qd5.d.mts} +129 -2
- package/dist/{analyticsEvent-DvjB92kK.d.ts → eventQueue-rV1dtJJR.d.ts} +129 -2
- package/dist/index.d.mts +157 -3
- package/dist/index.d.ts +157 -3
- package/dist/index.js +407 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +402 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/analytics/contextEnvelope.ts +72 -0
- package/src/analytics/eventQueue.ts +331 -0
- package/src/analytics/index.ts +8 -0
- package/src/index.ts +17 -0
- package/src/session-analytics/index.ts +20 -0
- package/src/session-analytics/lifecycle.ts +236 -0
- package/src/session-analytics/reportSessionStart.ts +27 -7
- package/src/session-analytics/useLifecycleEvents.ts +184 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lifecycle — the TOP-OF-FUNNEL app lifecycle events that sit ABOVE onboarding: `app.first_open`
|
|
3
|
+
* (once ever per install) and `app.session_started` (per app-open). They compose with, and never
|
|
4
|
+
* duplicate, the events the funnel already records.
|
|
5
|
+
*
|
|
6
|
+
* WHERE THIS FITS (one funnel, no double counting):
|
|
7
|
+
* • `app.first_open` → THIS module, once ever (persisted flag). The in-app "install" proxy.
|
|
8
|
+
* • `app.session_started` → the EXISTING {@link reportSessionStart} emitter (per app-open). This
|
|
9
|
+
* module REUSES it (routed through the offline queue), never re-emits.
|
|
10
|
+
* • onboarding started / completed / activated → ALREADY recorded: the server writes
|
|
11
|
+
* `session_started` + `completed` during the A2A flow, and the client reports `dropped` /
|
|
12
|
+
* `client_fallback` / `identify` via `reportClientEvent`. This module does NOT touch them —
|
|
13
|
+
* emitting them here would double-count. It only ADDS the two lifecycle events above them.
|
|
14
|
+
*
|
|
15
|
+
* WIRE CONTRACT (identical to `app.session_started`): both lifecycle events are stored as
|
|
16
|
+
* `event_type='app_event'` with the name in `question_key` (the server's `_event_name` returns
|
|
17
|
+
* `question_key` for an `app_event`, which a trigger matches). `app.first_open` uses the SAME
|
|
18
|
+
* `app.*` namespace + the SAME event shape as `app.session_started` — no bespoke `event_type`,
|
|
19
|
+
* no invented contract. `device_key` rides in the non-PII `user_context` bucket; `app_id` +
|
|
20
|
+
* `environment` are filled server-side.
|
|
21
|
+
*
|
|
22
|
+
* TWO FIRST-CLASS ENTRY POINTS (mirrors reportSessionStart vs useSessionStart):
|
|
23
|
+
* • `reportFirstOpen(...)` / `wireLifecycleEvents(...)` — pure, React-free, for a host that owns
|
|
24
|
+
* its own app-open path.
|
|
25
|
+
* • `useLifecycleEvents(...)` — the batteries-included React hook (in `useLifecycleEvents.ts`).
|
|
26
|
+
*
|
|
27
|
+
* OFFLINE-FIRST: pass the Brief-01 event queue's `enqueue` as the `sink` and every lifecycle event
|
|
28
|
+
* is buffered + persisted + retried instead of a blind fetch. Without a sink they degrade to a
|
|
29
|
+
* direct fire-and-forget POST.
|
|
30
|
+
*
|
|
31
|
+
* PRIVACY (hard rule, same as the rest of the kit): NO GPS/location, NO advertising id. Nothing
|
|
32
|
+
* here carries a coordinate or an ad id — a host adopting it changes no store privacy declaration.
|
|
33
|
+
*
|
|
34
|
+
* FIRE-AND-FORGET: like every analytics path in the kit, nothing here throws into the UI, awaits
|
|
35
|
+
* in the caller, or hangs the app — storage reads race a short timeout, writes swallow errors.
|
|
36
|
+
*/
|
|
37
|
+
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
38
|
+
import type { DeviceContext } from "../device/deviceContext";
|
|
39
|
+
import { sanitizeUserId } from "../identity/userIdentity";
|
|
40
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
41
|
+
import { reportSessionStart, SESSION_STARTED_EVENT } from "./reportSessionStart";
|
|
42
|
+
|
|
43
|
+
/** The canonical event name for the first-ever app open. Same `app.*` namespace as
|
|
44
|
+
* {@link SESSION_STARTED_EVENT}; a trigger keys off this exact string. */
|
|
45
|
+
export const FIRST_OPEN_EVENT = "app.first_open" as const;
|
|
46
|
+
|
|
47
|
+
/** Storage key for the once-ever first-open flag, e.g. `wireai:first_open:myelino`. Mirrors the
|
|
48
|
+
* `wireai:<concern>:<appId>` namespacing of {@link sessionStorageKey}. */
|
|
49
|
+
export const firstOpenStorageKey = (appId: string): string => `wireai:first_open:${appId}`;
|
|
50
|
+
|
|
51
|
+
/** Ceiling on the flag read — a hung adapter degrades to the in-memory latch, never a stuck gate. */
|
|
52
|
+
const READ_TIMEOUT_MS = 1_500;
|
|
53
|
+
|
|
54
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
|
|
55
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
56
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
57
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
58
|
+
});
|
|
59
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ── First-open in-memory latch ───────────────────────────────────────────────
|
|
63
|
+
// Covers TWO cases the persisted flag can't: (1) two near-simultaneous calls in one process (the
|
|
64
|
+
// flag read is async, so a synchronous latch is what guarantees at-most-one before any await), and
|
|
65
|
+
// (2) the documented DEGRADED no-storage mode (fires once per process). Keyed by appId.
|
|
66
|
+
const _firstOpenLatched = new Set<string>();
|
|
67
|
+
|
|
68
|
+
/** Test-only: forget the first-open latch so a unit test starts from a clean process state. */
|
|
69
|
+
export const resetFirstOpenLatch = (): void => {
|
|
70
|
+
_firstOpenLatched.clear();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Shared inputs for a lifecycle event. Everything is optional except a transport (`target` for the
|
|
74
|
+
* direct-POST fallback, or a `sink`). A pre-auth open (no user yet) is a valid device-only event. */
|
|
75
|
+
export interface LifecycleEventInput {
|
|
76
|
+
/** Where to POST if no `sink` is wired (the tenant transport, same as `WireOnboardingConfig`). */
|
|
77
|
+
target?: ClientEventTarget;
|
|
78
|
+
/** Preferred transport: route the built event HERE (the offline queue's `enqueue`). */
|
|
79
|
+
sink?: (event: ClientEvent) => void;
|
|
80
|
+
/** The per-open session id. Defaults to a fresh `makeSessionId()`. */
|
|
81
|
+
sessionId?: string;
|
|
82
|
+
/** The host's OPAQUE pseudonymous user id (NOT PII). Sanitized + capped; omitted pre-auth. */
|
|
83
|
+
userId?: string;
|
|
84
|
+
/** A stable, non-PII device id the host owns. Rides in `user_context.device_key`. */
|
|
85
|
+
deviceKey?: string;
|
|
86
|
+
/** The host's local open-counter value. Drives `returning` + "Nth session". */
|
|
87
|
+
sessionCount?: number;
|
|
88
|
+
/** Host app version (e.g. "1.4.2"), if cheaply available. */
|
|
89
|
+
appVersion?: string;
|
|
90
|
+
/** Platform string (e.g. "ios"), if cheaply available. */
|
|
91
|
+
platform?: string;
|
|
92
|
+
/** An optional richer device snapshot (from `collectDeviceContext()`); the hook fills this. */
|
|
93
|
+
device?: DeviceContext;
|
|
94
|
+
/** Small non-PII extras, stored as a JSON string in the event `meta`. */
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Options for {@link reportFirstOpen}. Adds the once-ever persistence inputs on top of the shared
|
|
99
|
+
* lifecycle inputs. Without `storage` it degrades to the in-memory latch (once per process). */
|
|
100
|
+
export interface ReportFirstOpenOptions extends LifecycleEventInput {
|
|
101
|
+
/** Host persistence (AsyncStorage subset). The once-ever flag lives here — it is what survives an
|
|
102
|
+
* app kill. Omit it for the documented degraded (in-memory, once-per-process) mode. */
|
|
103
|
+
storage?: WireOnboardingStorage;
|
|
104
|
+
/** Tenant/app id — namespaces the persisted flag (`wireai:first_open:<appId>`). */
|
|
105
|
+
appId?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the `app_event` payload shared by `app.first_open` and `app.session_started`. This is a
|
|
110
|
+
* BYTE-FOR-BYTE mirror of the field logic in {@link reportSessionStart} — same `event_type`, same
|
|
111
|
+
* `question_key`-as-name contract, same `user_context` bucket, same optional fields — so the two
|
|
112
|
+
* lifecycle events are one contract with two names. Do NOT let these diverge (see lifecycle.test).
|
|
113
|
+
*/
|
|
114
|
+
const buildLifecycleEvent = (questionKey: string, opts: LifecycleEventInput): Record<string, unknown> => {
|
|
115
|
+
const userContext: Record<string, string | number | boolean> = {};
|
|
116
|
+
if (opts.deviceKey) userContext.device_key = opts.deviceKey;
|
|
117
|
+
if (typeof opts.sessionCount === "number" && Number.isFinite(opts.sessionCount)) {
|
|
118
|
+
userContext.session_count = opts.sessionCount;
|
|
119
|
+
userContext.returning = opts.sessionCount > 1;
|
|
120
|
+
}
|
|
121
|
+
if (opts.appVersion) userContext.app_version = opts.appVersion;
|
|
122
|
+
if (opts.platform) userContext.platform = opts.platform;
|
|
123
|
+
|
|
124
|
+
const event: Record<string, unknown> = {
|
|
125
|
+
event_type: "app_event",
|
|
126
|
+
question_key: questionKey,
|
|
127
|
+
session_id: opts.sessionId ?? makeSessionId(),
|
|
128
|
+
};
|
|
129
|
+
const userId = sanitizeUserId(opts.userId);
|
|
130
|
+
if (userId) event.user_id = userId;
|
|
131
|
+
if (Object.keys(userContext).length > 0) event.user_context = userContext;
|
|
132
|
+
if (opts.device) event.device = opts.device;
|
|
133
|
+
if (opts.meta && Object.keys(opts.meta).length > 0) event.meta = JSON.stringify(opts.meta);
|
|
134
|
+
return event;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Route a built event to the wired sink, else a direct fire-and-forget POST. Never throws. */
|
|
138
|
+
const routeLifecycleEvent = (event: Record<string, unknown>, opts: LifecycleEventInput): void => {
|
|
139
|
+
try {
|
|
140
|
+
if (opts.sink) {
|
|
141
|
+
opts.sink(event as ClientEvent);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const target = opts.target;
|
|
145
|
+
if (!target?.serverUrl) return;
|
|
146
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
147
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
148
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
149
|
+
void fetch(url, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify({ events: [event] }),
|
|
153
|
+
}).catch(() => {
|
|
154
|
+
// Network/transport error — analytics is best-effort, swallow.
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
// A throwing sink, URL/JSON error, or a missing fetch — swallow.
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const emitFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
162
|
+
routeLifecycleEvent(buildLifecycleEvent(FIRST_OPEN_EVENT, opts), opts);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Emit `app.first_open` EXACTLY ONCE EVER per install. Fire-and-forget; returns immediately.
|
|
167
|
+
*
|
|
168
|
+
* • With `storage`: reads the persisted flag (`wireai:first_open:<appId>`). Absent → emit, then
|
|
169
|
+
* write the flag (survives app kills, so a second launch is a no-op). Present → no-op.
|
|
170
|
+
* • Race guard: an in-memory latch is set SYNCHRONOUSLY before the async read, so two
|
|
171
|
+
* near-simultaneous calls fire at most once.
|
|
172
|
+
* • Without `storage`: degraded mode — fires once per PROCESS via the latch only (documented).
|
|
173
|
+
*/
|
|
174
|
+
export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
175
|
+
const appId = opts.appId ?? "default";
|
|
176
|
+
|
|
177
|
+
// Synchronous latch FIRST: guarantees at-most-one before any await (same-process race guard) and
|
|
178
|
+
// is the sole guard in the no-storage degraded mode.
|
|
179
|
+
if (_firstOpenLatched.has(appId)) return;
|
|
180
|
+
_firstOpenLatched.add(appId);
|
|
181
|
+
|
|
182
|
+
const storage = opts.storage;
|
|
183
|
+
if (!storage) {
|
|
184
|
+
// Degraded: no persistence to survive a kill → the latch fires it once per process.
|
|
185
|
+
emitFirstOpen(opts);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const key = firstOpenStorageKey(appId);
|
|
190
|
+
void (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const seen = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
193
|
+
// A prior launch already fired + wrote the flag → once-ever satisfied, no-op.
|
|
194
|
+
if (seen) return;
|
|
195
|
+
emitFirstOpen(opts);
|
|
196
|
+
try {
|
|
197
|
+
// Best-effort persist: a failed write just means the next launch may re-fire once.
|
|
198
|
+
void storage.setItem(key, JSON.stringify({ ts: Date.now() })).catch(() => {});
|
|
199
|
+
} catch {
|
|
200
|
+
// Missing/broken storage write — swallow.
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Unreadable flag → the in-memory latch still caps us at one fire this process.
|
|
204
|
+
emitFirstOpen(opts);
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Options for {@link wireLifecycleEvents}: the shared lifecycle inputs + first-open persistence. */
|
|
210
|
+
export interface WireLifecycleOptions extends ReportFirstOpenOptions {}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Wire BOTH lifecycle events in one call for a host that owns its own app-open path (the non-hook
|
|
214
|
+
* counterpart to {@link useLifecycleEvents}). Fires `app.first_open` (once ever) and one
|
|
215
|
+
* `app.session_started` for THIS open through the EXISTING {@link reportSessionStart} emitter (so
|
|
216
|
+
* the once-per-open guard still applies — pass the same `sessionId` and it never double-fires).
|
|
217
|
+
* Route both through the same `sink` (the offline queue) to buffer them. Fire-and-forget.
|
|
218
|
+
*/
|
|
219
|
+
export const wireLifecycleEvents = (opts: WireLifecycleOptions): void => {
|
|
220
|
+
reportFirstOpen(opts);
|
|
221
|
+
reportSessionStart({
|
|
222
|
+
target: opts.target,
|
|
223
|
+
sink: opts.sink,
|
|
224
|
+
sessionId: opts.sessionId,
|
|
225
|
+
userId: opts.userId,
|
|
226
|
+
deviceKey: opts.deviceKey,
|
|
227
|
+
sessionCount: opts.sessionCount,
|
|
228
|
+
appVersion: opts.appVersion,
|
|
229
|
+
platform: opts.platform,
|
|
230
|
+
device: opts.device,
|
|
231
|
+
meta: opts.meta,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Re-exported so callers wiring lifecycle events have the session name alongside the first-open one.
|
|
236
|
+
export { SESSION_STARTED_EVENT };
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* awaits, and swallows a missing target / bad URL / missing fetch / network error. Analytics must
|
|
32
32
|
* never be able to break the app.
|
|
33
33
|
*/
|
|
34
|
-
import { makeSessionId, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
34
|
+
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
35
35
|
import type { DeviceContext } from "../device/deviceContext";
|
|
36
36
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
37
37
|
|
|
@@ -68,6 +68,15 @@ export interface ReportSessionStartOptions {
|
|
|
68
68
|
meta?: Record<string, unknown>;
|
|
69
69
|
/** Set `false` to bypass the once-per-open guard (default on). See {@link resetSessionStartGuard}. */
|
|
70
70
|
once?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* OPTIONAL transport sink. When provided, the built `app.session_started` event is routed HERE
|
|
73
|
+
* (e.g. the offline-first event queue's `enqueue`) INSTEAD of this emitter's own direct `fetch`,
|
|
74
|
+
* while KEEPING the once-per-open guard above. This is how the lifecycle wiring
|
|
75
|
+
* (`useLifecycleEvents` / `wireLifecycleEvents`) offline-buffers session-start WITHOUT adding a
|
|
76
|
+
* second session emitter: one emitter, one guard, now durable. Omit it for the direct-POST path.
|
|
77
|
+
* A throwing sink is swallowed — analytics must never surface into the UI.
|
|
78
|
+
*/
|
|
79
|
+
sink?: (event: ClientEvent) => void;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
// ── Once-per-open guard ──────────────────────────────────────────────────────
|
|
@@ -89,7 +98,9 @@ export const resetSessionStartGuard = (): void => {
|
|
|
89
98
|
*/
|
|
90
99
|
export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
91
100
|
const target = opts.target;
|
|
92
|
-
|
|
101
|
+
// A wired sink is a valid transport on its own — it owns the target (the queue's serverUrl), so
|
|
102
|
+
// this emitter needs its own `target` ONLY for the direct-fetch fallback.
|
|
103
|
+
if (!opts.sink && !target?.serverUrl) return;
|
|
93
104
|
|
|
94
105
|
const sessionId = opts.sessionId ?? makeSessionId();
|
|
95
106
|
|
|
@@ -105,10 +116,6 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
try {
|
|
108
|
-
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
109
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
110
|
-
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
111
|
-
|
|
112
119
|
// Non-PII correlation bucket. device_key is read by the server to group a device's sessions;
|
|
113
120
|
// session_count / returning / app_version / platform feed per-user retention analytics.
|
|
114
121
|
const userContext: Record<string, string | number | boolean> = {};
|
|
@@ -131,6 +138,19 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
131
138
|
if (opts.device) event.device = opts.device;
|
|
132
139
|
if (opts.meta && Object.keys(opts.meta).length > 0) event.meta = JSON.stringify(opts.meta);
|
|
133
140
|
|
|
141
|
+
// Preferred transport: an injected sink (the offline-first event queue's `enqueue`), which
|
|
142
|
+
// buffers + persists + retries. The once-guard above already ran, so this stays the single
|
|
143
|
+
// session emitter with a single guard — now durable.
|
|
144
|
+
if (opts.sink) {
|
|
145
|
+
opts.sink(event as ClientEvent);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fallback: this emitter's own direct POST when no sink is wired.
|
|
150
|
+
if (!target?.serverUrl) return;
|
|
151
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
152
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
153
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
134
154
|
void fetch(url, {
|
|
135
155
|
method: "POST",
|
|
136
156
|
headers,
|
|
@@ -139,6 +159,6 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
139
159
|
// Network/transport error — analytics is best-effort, swallow.
|
|
140
160
|
});
|
|
141
161
|
} catch {
|
|
142
|
-
// URL construction, JSON serialization, or a missing fetch — swallow.
|
|
162
|
+
// URL construction, JSON serialization, a throwing sink, or a missing fetch — swallow.
|
|
143
163
|
}
|
|
144
164
|
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLifecycleEvents — the batteries-included React hook that wires the TOP-OF-FUNNEL lifecycle
|
|
3
|
+
* events in ONE place: `app.first_open` (once ever) on mount, and `app.session_started` on every
|
|
4
|
+
* real app-open, both offline-buffered through the Brief-01 event queue.
|
|
5
|
+
*
|
|
6
|
+
* ONE emitter, one guard, no double-emit: session-start goes through the EXISTING
|
|
7
|
+
* {@link reportSessionStart} emitter (routed via a `sink`), NOT a second session emitter — so a
|
|
8
|
+
* host must use EITHER this hook OR `useSessionStart`, never both (both would fire an app-open).
|
|
9
|
+
* The once-per-open guard dedupes re-renders within this hook.
|
|
10
|
+
*
|
|
11
|
+
* Firing moments mirror {@link useSessionStart} exactly:
|
|
12
|
+
* • ON MOUNT — the app opened (cold start / provider first render). `app.first_open` fires here
|
|
13
|
+
* too (once ever, gated by the persisted flag in `reportFirstOpen`).
|
|
14
|
+
* • ON FOREGROUND after a real background of at least {@link BACKGROUND_SESSION_MS} (30 min) — a
|
|
15
|
+
* new app-open, so a fresh `app.session_started` fires. A quick app-switch does NOT count.
|
|
16
|
+
*
|
|
17
|
+
* OFFLINE-FIRST by default: pass your shared {@link EventQueue}'s `enqueue` as `options.sink` to
|
|
18
|
+
* route both events through your one queue. If you pass no sink, the hook lazily creates its OWN
|
|
19
|
+
* offline queue (dedicated storage key, so it never collides with your main queue) from the config
|
|
20
|
+
* + storage. Either way lifecycle events are buffered + persisted + retried, never lost offline.
|
|
21
|
+
*
|
|
22
|
+
* Dependency policy: `AppState` + `Platform` are RN built-ins (no new dep); the device snapshot is
|
|
23
|
+
* the kit's own dependency-free `collectDeviceContext()`; the queue is the dependency-free Brief-01
|
|
24
|
+
* `createEventQueue`. The pure `reportFirstOpen` / `wireLifecycleEvents` (in lifecycle.ts) import no
|
|
25
|
+
* React — this hook is the only React surface.
|
|
26
|
+
*/
|
|
27
|
+
import { useEffect, useRef } from "react";
|
|
28
|
+
import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
29
|
+
|
|
30
|
+
import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
|
|
31
|
+
import type { ClientEvent, ClientEventTarget } from "../analytics/reportClientEvent";
|
|
32
|
+
import { collectDeviceContext } from "../device/deviceContext";
|
|
33
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
|
+
import { reportFirstOpen } from "./lifecycle";
|
|
35
|
+
import { reportSessionStart } from "./reportSessionStart";
|
|
36
|
+
import { BACKGROUND_SESSION_MS } from "./useSessionStart";
|
|
37
|
+
|
|
38
|
+
/** Tenant transport + host persistence for the lifecycle wiring. Same creds as `WireOnboardingConfig`. */
|
|
39
|
+
export interface LifecycleConfig {
|
|
40
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
|
|
41
|
+
serverUrl?: string;
|
|
42
|
+
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
43
|
+
apiKey?: string;
|
|
44
|
+
/** Host app version (e.g. "1.4.2"), forwarded for release segmentation. Optional. */
|
|
45
|
+
appVersion?: string;
|
|
46
|
+
/** Tenant/app id — namespaces the first-open flag AND the hook's internal queue storage key. */
|
|
47
|
+
appId?: string;
|
|
48
|
+
/** Host storage (AsyncStorage subset). Enables the persisted once-ever first-open flag AND the
|
|
49
|
+
* offline durability of the hook's internal queue. Omit it and both degrade to in-memory. */
|
|
50
|
+
storage?: WireOnboardingStorage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Per-open identity + wiring the host supplies. All optional: a pre-auth open is device-only. */
|
|
54
|
+
export interface UseLifecycleEventsOptions {
|
|
55
|
+
/** The host's opaque pseudonymous user id (NOT PII). Omitted before the user authenticates. */
|
|
56
|
+
userId?: string;
|
|
57
|
+
/** The host's local open-counter value for this open. Drives `returning` + "Nth session". */
|
|
58
|
+
sessionCount?: number;
|
|
59
|
+
/** A stable, non-PII device id the host owns. Groups this device's sessions server-side. */
|
|
60
|
+
deviceKey?: string;
|
|
61
|
+
/** Small non-PII extras forwarded on the event `meta`. */
|
|
62
|
+
meta?: Record<string, unknown>;
|
|
63
|
+
/** Set `false` to disable firing (e.g. behind a consent gate). Default enabled. */
|
|
64
|
+
enabled?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Explicit transport sink (e.g. an existing shared `EventQueue.enqueue`). When provided, BOTH
|
|
67
|
+
* lifecycle events route here and the hook does NOT create its own queue — pass this to share ONE
|
|
68
|
+
* offline queue across the kit's analytics (screen tracking + lifecycle).
|
|
69
|
+
*/
|
|
70
|
+
sink?: (event: ClientEvent) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Context envelope (or provider) for the hook's internally-created queue. Ignored when `sink` is
|
|
73
|
+
* supplied (the host's queue owns envelope stamping).
|
|
74
|
+
*/
|
|
75
|
+
envelope?: EnvelopeSource;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fire `app.first_open` (once ever) + `app.session_started` (per open), offline-buffered. Returns
|
|
80
|
+
* nothing — a side-effecting hook. Safe to call with inline options (read through a ref, so
|
|
81
|
+
* changing `userId`/`sessionCount` never re-fires a session).
|
|
82
|
+
*/
|
|
83
|
+
export const useLifecycleEvents = (
|
|
84
|
+
config: LifecycleConfig | undefined,
|
|
85
|
+
options: UseLifecycleEventsOptions = {},
|
|
86
|
+
): void => {
|
|
87
|
+
// Latest options/config through a ref so the mount/resume fires read fresh values without listing
|
|
88
|
+
// them as effect deps (which would re-fire a session on every prop change).
|
|
89
|
+
const latest = useRef({ config, options });
|
|
90
|
+
latest.current = { config, options };
|
|
91
|
+
|
|
92
|
+
// The resolved sink is created at most once and reused for every fire this mount.
|
|
93
|
+
const queueRef = useRef<EventQueue | undefined>(undefined);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const resolveSink = (): ((event: ClientEvent) => void) | undefined => {
|
|
97
|
+
const { config: cfg, options: opts } = latest.current;
|
|
98
|
+
// Host-owned shared queue wins — one queue across the whole kit.
|
|
99
|
+
if (opts.sink) return opts.sink;
|
|
100
|
+
// No transport → no sink (events no-op rather than buffer forever against a dead target).
|
|
101
|
+
if (!cfg?.serverUrl) return undefined;
|
|
102
|
+
if (!queueRef.current) {
|
|
103
|
+
queueRef.current = createEventQueue({
|
|
104
|
+
target: { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" },
|
|
105
|
+
storage: cfg.storage,
|
|
106
|
+
// Dedicated key so the hook's internal queue never collides with a host's main queue.
|
|
107
|
+
storageKey: `wireai:evtq:lifecycle:${cfg.appId ?? "default"}`,
|
|
108
|
+
envelope: opts.envelope,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return queueRef.current.enqueue;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const targetOf = (cfg: LifecycleConfig | undefined): ClientEventTarget | undefined =>
|
|
115
|
+
cfg?.serverUrl ? { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" } : undefined;
|
|
116
|
+
|
|
117
|
+
// 1) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen).
|
|
118
|
+
{
|
|
119
|
+
const { config: cfg, options: opts } = latest.current;
|
|
120
|
+
if (opts.enabled !== false) {
|
|
121
|
+
const device = collectDeviceContext();
|
|
122
|
+
if (cfg?.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
|
|
123
|
+
reportFirstOpen({
|
|
124
|
+
target: targetOf(cfg),
|
|
125
|
+
sink: resolveSink(),
|
|
126
|
+
storage: cfg?.storage,
|
|
127
|
+
appId: cfg?.appId,
|
|
128
|
+
userId: opts.userId,
|
|
129
|
+
deviceKey: opts.deviceKey,
|
|
130
|
+
sessionCount: opts.sessionCount,
|
|
131
|
+
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
132
|
+
platform: Platform.OS,
|
|
133
|
+
device,
|
|
134
|
+
meta: opts.meta,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2) session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
|
|
140
|
+
const fireSession = () => {
|
|
141
|
+
const { config: cfg, options: opts } = latest.current;
|
|
142
|
+
if (opts.enabled === false) return;
|
|
143
|
+
if (!cfg?.serverUrl && !opts.sink) return;
|
|
144
|
+
const device = collectDeviceContext();
|
|
145
|
+
if (cfg?.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
|
|
146
|
+
reportSessionStart({
|
|
147
|
+
target: targetOf(cfg),
|
|
148
|
+
sink: resolveSink(),
|
|
149
|
+
// A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
|
|
150
|
+
userId: opts.userId,
|
|
151
|
+
deviceKey: opts.deviceKey,
|
|
152
|
+
sessionCount: opts.sessionCount,
|
|
153
|
+
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
154
|
+
platform: Platform.OS,
|
|
155
|
+
device,
|
|
156
|
+
meta: opts.meta,
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Mount = an app-open.
|
|
161
|
+
fireSession();
|
|
162
|
+
|
|
163
|
+
// Foreground after a real background = a new app-open.
|
|
164
|
+
let backgroundedAt: number | null = null;
|
|
165
|
+
const onChange = (state: AppStateStatus) => {
|
|
166
|
+
if (state === "background" || state === "inactive") {
|
|
167
|
+
if (backgroundedAt == null) backgroundedAt = Date.now();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (state === "active") {
|
|
171
|
+
const since = backgroundedAt;
|
|
172
|
+
backgroundedAt = null;
|
|
173
|
+
if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) fireSession();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const sub = AppState.addEventListener("change", onChange);
|
|
177
|
+
return () => {
|
|
178
|
+
// RN >= 0.65 returns a subscription with remove(); guard for older shims.
|
|
179
|
+
if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
|
|
180
|
+
};
|
|
181
|
+
// Mount-only effect: firing reads fresh values via `latest`, so no reactive deps.
|
|
182
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
183
|
+
}, []);
|
|
184
|
+
};
|