@wireai/activation 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.2.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
+ };
@@ -45,3 +45,15 @@ export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
45
45
  // ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
46
46
  export { createEventQueue } from "./eventQueue";
47
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
- export type ClientEventType = "screen_skipped" | "dropped" | "client_fallback" | "identify";
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
+ };