@wireai/activation 0.1.1 → 0.3.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 +70 -3
- package/dist/analytics/index.d.ts +70 -3
- package/dist/analytics/index.js +368 -0
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +365 -1
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{analyticsEvent-B8v3BZjM.d.mts → eventQueue-CA1d8Fmn.d.mts} +135 -4
- package/dist/{analyticsEvent-DvjB92kK.d.ts → eventQueue-CrNB9gzH.d.ts} +135 -4
- 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/analyticsFacade.ts +171 -0
- package/src/analytics/contextEnvelope.ts +72 -0
- package/src/analytics/eventQueue.ts +331 -0
- package/src/analytics/index.ts +20 -0
- package/src/analytics/reportClientEvent.ts +11 -2
- package/src/analytics/useAnalytics.ts +36 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analyticsFacade — a Segment/PostHog-shaped developer API (`track` / `screen` / `identify`)
|
|
3
|
+
* over the kit's OWN offline-first event queue. One package, one key, one line:
|
|
4
|
+
*
|
|
5
|
+
* const analytics = createAnalytics({ serverUrl, apiKey, storage });
|
|
6
|
+
* analytics.track("content_share", { source: "feed" });
|
|
7
|
+
* analytics.screen("Home", { tab: "explore" });
|
|
8
|
+
* analytics.identify("u_123", { plan: "pro" });
|
|
9
|
+
*
|
|
10
|
+
* WHY a façade: the primitives already exist (`createEventQueue` for durable offline-first
|
|
11
|
+
* transport, `buildContextEnvelope` for the non-PII device context, the `identify` client-event
|
|
12
|
+
* contract for user binding), but a host had to wire them together by hand. This composes them
|
|
13
|
+
* into the familiar analytics-SDK surface so a consumer gets the ergonomics with NO second
|
|
14
|
+
* install and NO second key — the same `{ serverUrl, apiKey }` creds as onboarding.
|
|
15
|
+
*
|
|
16
|
+
* ROUTING: every call builds a `ClientEvent` and `enqueue`s it. The queue stamps the context
|
|
17
|
+
* envelope (device + host scalars), persists offline, batches, retries, and dequeues on ack —
|
|
18
|
+
* so `track`/`screen`/`identify` inherit offline-first durability for free. `screen` routes
|
|
19
|
+
* through the SAME queue rather than the direct `reportAppEvent` POST (a deliberate upgrade:
|
|
20
|
+
* screen views survive being offline too).
|
|
21
|
+
*
|
|
22
|
+
* DEPENDENCY-FREE + TREE-SHAKEABLE: this module imports ONLY the queue, the envelope builder,
|
|
23
|
+
* the client-event types + session-id seed, and the identity sanitizer. It reaches NO
|
|
24
|
+
* onboarding / showcase / review UI, so an analytics-only consumer bundles none of it. React is
|
|
25
|
+
* absent here on purpose — the optional React glue is the thin `useAnalytics` hook.
|
|
26
|
+
*
|
|
27
|
+
* FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
|
|
28
|
+
*/
|
|
29
|
+
import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
|
|
30
|
+
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
31
|
+
import { makeSessionId, type ClientEvent } from "./reportClientEvent";
|
|
32
|
+
import { sanitizeUserId } from "../identity/userIdentity";
|
|
33
|
+
|
|
34
|
+
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
35
|
+
export type AnalyticsProps = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tenant transport + context inputs for {@link createAnalytics}. `serverUrl`/`apiKey` are the
|
|
39
|
+
* SAME creds as onboarding (never a second key). The rest feed the context envelope + the queue's
|
|
40
|
+
* offline persistence — all optional.
|
|
41
|
+
*/
|
|
42
|
+
export type CreateAnalyticsConfig = {
|
|
43
|
+
/** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
|
|
44
|
+
serverUrl: string;
|
|
45
|
+
/** Tenant API key; sent as `Authorization: Bearer`. */
|
|
46
|
+
apiKey: string;
|
|
47
|
+
/**
|
|
48
|
+
* Correlation id shared by every event from this instance (and the `identify` event). Defaults
|
|
49
|
+
* to a fresh `makeSessionId()` at creation so all events agree on one id per analytics instance.
|
|
50
|
+
*/
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
/** Tenant/app id used to namespace the queue's default storage key (`wireai:evtq:<appId>`). */
|
|
53
|
+
appId?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Host persistence (AsyncStorage-compatible subset) for offline-first durability. When omitted,
|
|
56
|
+
* the queue runs in the documented in-memory mode (survives re-renders, not app kills).
|
|
57
|
+
*/
|
|
58
|
+
storage?: EventQueueOptions["storage"];
|
|
59
|
+
/** Host app version, e.g. "1.4.2" (host-injected; stamped onto every event's context). */
|
|
60
|
+
appVersion?: string;
|
|
61
|
+
/** Host native build number, e.g. "412" (host-injected). */
|
|
62
|
+
appBuild?: string;
|
|
63
|
+
/** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
|
|
64
|
+
networkType?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
68
|
+
export type AnalyticsOptions = Partial<
|
|
69
|
+
Pick<EventQueueOptions, "maxSize" | "batchSize" | "baseBackoffMs" | "maxBackoffMs" | "maxRetries">
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The developer-facing analytics surface. `track`/`screen`/`identify` are the Segment/PostHog-shaped
|
|
74
|
+
* API; `flush`/`notifyOnline`/`size` expose the underlying queue so a host can drive reconnect
|
|
75
|
+
* draining (load-bearing for offline-first) and inspect the pending buffer.
|
|
76
|
+
*/
|
|
77
|
+
export type Analytics = {
|
|
78
|
+
/** Record an in-app event: `event_type='app_event'`, `question_key=<event>`, `props`→`meta`. */
|
|
79
|
+
track(event: string, props?: AnalyticsProps): void;
|
|
80
|
+
/** Record a screen view: `event_type='app_event'`, `question_key='screen'`, `meta={ screen, ...props }`. */
|
|
81
|
+
screen(name: string, props?: AnalyticsProps): void;
|
|
82
|
+
/** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
|
|
83
|
+
identify(userId: string, traits?: AnalyticsProps): void;
|
|
84
|
+
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
85
|
+
flush(): void;
|
|
86
|
+
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
87
|
+
notifyOnline(): void;
|
|
88
|
+
/** Current pending (in-memory) count. */
|
|
89
|
+
size(): number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first
|
|
94
|
+
* queue over the tenant transport, and passes a fresh-per-event context envelope PROVIDER so the
|
|
95
|
+
* connectivity type is read at enqueue time. The bound user id starts unset (see `identify`).
|
|
96
|
+
*/
|
|
97
|
+
export const createAnalytics = (
|
|
98
|
+
config: CreateAnalyticsConfig,
|
|
99
|
+
options: AnalyticsOptions = {},
|
|
100
|
+
): Analytics => {
|
|
101
|
+
const sessionId = config.sessionId ?? makeSessionId();
|
|
102
|
+
|
|
103
|
+
// A provider (not a fixed value) so `networkType` is evaluated fresh on every enqueue.
|
|
104
|
+
const envelope = (): ContextEnvelope =>
|
|
105
|
+
buildContextEnvelope({
|
|
106
|
+
sessionId,
|
|
107
|
+
appVersion: config.appVersion,
|
|
108
|
+
appBuild: config.appBuild,
|
|
109
|
+
networkType: config.networkType,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const queue: EventQueue = createEventQueue({
|
|
113
|
+
target: { serverUrl: config.serverUrl, apiKey: config.apiKey },
|
|
114
|
+
storage: config.storage,
|
|
115
|
+
appId: config.appId,
|
|
116
|
+
envelope,
|
|
117
|
+
...options,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Per-session, in-memory user binding. Not persisted across launches (that is a follow-up).
|
|
121
|
+
let boundUserId: string | undefined;
|
|
122
|
+
|
|
123
|
+
const track = (event: string, props?: AnalyticsProps): void => {
|
|
124
|
+
if (!event) return;
|
|
125
|
+
const clientEvent: ClientEvent = {
|
|
126
|
+
event_type: "app_event",
|
|
127
|
+
session_id: sessionId,
|
|
128
|
+
question_key: event,
|
|
129
|
+
};
|
|
130
|
+
if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
|
|
131
|
+
if (boundUserId) clientEvent.user_id = boundUserId;
|
|
132
|
+
queue.enqueue(clientEvent);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const screen = (name: string, props?: AnalyticsProps): void => {
|
|
136
|
+
if (!name) return;
|
|
137
|
+
// Reuse the K6 screen event shape (question_key='screen'); route through the queue for offline-first.
|
|
138
|
+
const meta = { screen: name, ...(props ?? {}) };
|
|
139
|
+
const clientEvent: ClientEvent = {
|
|
140
|
+
event_type: "app_event",
|
|
141
|
+
session_id: sessionId,
|
|
142
|
+
question_key: "screen",
|
|
143
|
+
meta: JSON.stringify(meta),
|
|
144
|
+
};
|
|
145
|
+
if (boundUserId) clientEvent.user_id = boundUserId;
|
|
146
|
+
queue.enqueue(clientEvent);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const identify = (userId: string, traits?: AnalyticsProps): void => {
|
|
150
|
+
const clean = sanitizeUserId(userId);
|
|
151
|
+
// Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
|
|
152
|
+
if (!clean) return;
|
|
153
|
+
boundUserId = clean;
|
|
154
|
+
const clientEvent: ClientEvent = {
|
|
155
|
+
event_type: "identify",
|
|
156
|
+
session_id: sessionId,
|
|
157
|
+
user_id: clean,
|
|
158
|
+
};
|
|
159
|
+
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
160
|
+
queue.enqueue(clientEvent);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
track,
|
|
165
|
+
screen,
|
|
166
|
+
identify,
|
|
167
|
+
flush: queue.flush,
|
|
168
|
+
notifyOnline: queue.notifyOnline,
|
|
169
|
+
size: queue.size,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
@@ -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
|
+
};
|
package/src/analytics/index.ts
CHANGED
|
@@ -37,3 +37,23 @@ 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";
|
|
48
|
+
|
|
49
|
+
// ─── Developer-facing analytics façade (track / screen / identify) over the queue ─
|
|
50
|
+
export { createAnalytics } from "./analyticsFacade";
|
|
51
|
+
export type {
|
|
52
|
+
Analytics,
|
|
53
|
+
AnalyticsOptions,
|
|
54
|
+
AnalyticsProps,
|
|
55
|
+
CreateAnalyticsConfig,
|
|
56
|
+
} from "./analyticsFacade";
|
|
57
|
+
|
|
58
|
+
// ─── The thin optional React hook over the façade ─────────────────────────────
|
|
59
|
+
export { useAnalytics } from "./useAnalytics";
|
|
@@ -35,8 +35,17 @@ import type { DeviceContext } from "../device/deviceContext";
|
|
|
35
35
|
* This is the SINGLE fallback signal — hosts must NOT also report their own.
|
|
36
36
|
* `identify` binds the host's opaque `user_id` to this `session_id` (late binding — the user
|
|
37
37
|
* registered during/after onboarding). It carries no funnel weight; the server maps the
|
|
38
|
-
* session to the user and back-fills a `session_started` if it never saw the session.
|
|
39
|
-
|
|
38
|
+
* session to the user and back-fills a `session_started` if it never saw the session.
|
|
39
|
+
* `app_event` is the host's own in-app event (the `createAnalytics` façade's `track`/`screen`
|
|
40
|
+
* route through the offline queue as first-class `ClientEvent`s). The server already ingests
|
|
41
|
+
* it — `reportAppEvent` (reviews/transport) has fired `event_type='app_event'` all along; this
|
|
42
|
+
* widening just lets the same shape flow through the durable queue instead of a blind POST. */
|
|
43
|
+
export type ClientEventType =
|
|
44
|
+
| "screen_skipped"
|
|
45
|
+
| "dropped"
|
|
46
|
+
| "client_fallback"
|
|
47
|
+
| "identify"
|
|
48
|
+
| "app_event";
|
|
40
49
|
|
|
41
50
|
/** One client-reported event. Mirrors the server's `OnboardingEvent` (client-settable fields). */
|
|
42
51
|
export type ClientEvent = {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAnalytics — a THIN optional React hook over the pure {@link createAnalytics} factory.
|
|
3
|
+
*
|
|
4
|
+
* It builds ONE analytics instance for the component's lifetime and returns it, so re-renders
|
|
5
|
+
* never rebuild the queue or lose the in-memory user binding. React is a REQUIRED peer of the
|
|
6
|
+
* kit, so importing it here is allowed; the hook adds NO other dependency. Mirrors the existing
|
|
7
|
+
* `createScreenTracker` / `useScreenTracking` split — the factory stays React-free, this is the
|
|
8
|
+
* glue.
|
|
9
|
+
*
|
|
10
|
+
* const analytics = useAnalytics({ serverUrl, apiKey, storage, appId });
|
|
11
|
+
* analytics.track("content_share", { source: "feed" });
|
|
12
|
+
* // ...on reconnect: analytics.notifyOnline();
|
|
13
|
+
*/
|
|
14
|
+
import { useRef } from "react";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createAnalytics,
|
|
18
|
+
type Analytics,
|
|
19
|
+
type AnalyticsOptions,
|
|
20
|
+
type CreateAnalyticsConfig,
|
|
21
|
+
} from "./analyticsFacade";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a per-mount analytics instance. `config`/`options` are read once at first render (the
|
|
25
|
+
* instance is stable for the component's lifetime, held in a ref). Returns the {@link Analytics}
|
|
26
|
+
* surface so the component can `track` / `screen` / `identify` and drive `notifyOnline` on reconnect.
|
|
27
|
+
*/
|
|
28
|
+
export const useAnalytics = (
|
|
29
|
+
config: CreateAnalyticsConfig,
|
|
30
|
+
options: AnalyticsOptions = {},
|
|
31
|
+
): Analytics => {
|
|
32
|
+
// One instance per mount; kept in a ref so re-renders never rebuild the queue or drop the binding.
|
|
33
|
+
const ref = useRef<Analytics | undefined>(undefined);
|
|
34
|
+
if (!ref.current) ref.current = createAnalytics(config, options);
|
|
35
|
+
return ref.current;
|
|
36
|
+
};
|