@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.
@@ -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
- if (!target?.serverUrl) return;
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
+ };