@wireai/activation 0.4.0 → 0.8.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/AGENTS.md +1 -1
- package/CHANGELOG.md +92 -0
- package/dist/analytics/index.d.mts +19 -2
- package/dist/analytics/index.d.ts +19 -2
- package/dist/analytics/index.js +258 -18
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +256 -19
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{eventQueue-CA1d8Fmn.d.mts → currentSession-d9CrBxwe.d.mts} +152 -16
- package/dist/{eventQueue-CrNB9gzH.d.ts → currentSession-f7LWcdWG.d.ts} +152 -16
- package/dist/index.d.mts +96 -32
- package/dist/index.d.ts +96 -32
- package/dist/index.js +214 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +201 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +4 -3
- package/src/analytics/analyticsFacade.ts +120 -12
- package/src/analytics/contextEnvelope.ts +13 -7
- package/src/analytics/currentSession.ts +35 -0
- package/src/analytics/index.ts +3 -0
- package/src/context/deviceId.ts +43 -0
- package/src/context/userContext.ts +210 -0
- package/src/device/appVersion.ts +103 -0
- package/src/device/deviceContext.ts +31 -6
- package/src/device/deviceModel.ts +93 -0
- package/src/identity/userIdentity.ts +5 -0
- package/src/index.ts +25 -0
- package/src/session-analytics/reportSessionStart.ts +7 -0
- package/src/session-analytics/useLifecycleEvents.ts +4 -2
- package/src/session-analytics/useSessionStart.ts +2 -1
- package/src/types.ts +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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>",
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -61,9 +61,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
61
61
|
const boundUserId = useMemo(() => sanitizeUserId(userId), [userId]);
|
|
62
62
|
|
|
63
63
|
// Privacy-label-neutral device snapshot, collected once per mount (no advertising IDs, no
|
|
64
|
-
// fingerprinting — see device/deviceContext.ts). `
|
|
65
|
-
//
|
|
66
|
-
// metadata and every client event so the backend can segment
|
|
64
|
+
// fingerprinting — see device/deviceContext.ts). `collectDeviceContext` already carries a
|
|
65
|
+
// best-effort auto-detected `appVersion`; an explicit host `config.appVersion` overrides it.
|
|
66
|
+
// Attached to BOTH the A2A session metadata and every client event so the backend can segment
|
|
67
|
+
// the funnel by device (and by app version).
|
|
67
68
|
const device = useMemo<DeviceContext>(() => {
|
|
68
69
|
const collected = collectDeviceContext();
|
|
69
70
|
return config.appVersion ? { ...collected, appVersion: config.appVersion } : collected;
|
|
@@ -27,8 +27,11 @@
|
|
|
27
27
|
* FIRE-AND-FORGET: no method throws into the UI or blocks — the queue already guarantees that.
|
|
28
28
|
*/
|
|
29
29
|
import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
|
|
30
|
+
import { getCurrentSessionId } from "./currentSession";
|
|
30
31
|
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
31
32
|
import { makeSessionId, type ClientEvent } from "./reportClientEvent";
|
|
33
|
+
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
34
|
+
import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
|
|
32
35
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
33
36
|
|
|
34
37
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
@@ -62,6 +65,17 @@ export type CreateAnalyticsConfig = {
|
|
|
62
65
|
appBuild?: string;
|
|
63
66
|
/** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
|
|
64
67
|
networkType?: string;
|
|
68
|
+
/**
|
|
69
|
+
* The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
|
|
70
|
+
* user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
|
|
71
|
+
* {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional.
|
|
72
|
+
*
|
|
73
|
+
* NOTE on `deviceKey`: you do NOT need to supply one. When omitted, the kit auto-mints a stable,
|
|
74
|
+
* non-PII per-install `device_key`, persists it via `storage`, and reuses it every open (in-memory
|
|
75
|
+
* fallback without storage) — so `user_context.device_key` is ALWAYS present for the server's
|
|
76
|
+
* review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
|
|
77
|
+
*/
|
|
78
|
+
userContext?: WireUserContext;
|
|
65
79
|
};
|
|
66
80
|
|
|
67
81
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
@@ -81,6 +95,12 @@ export type Analytics = {
|
|
|
81
95
|
screen(name: string, props?: AnalyticsProps): void;
|
|
82
96
|
/** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
|
|
83
97
|
identify(userId: string, traits?: AnalyticsProps): void;
|
|
98
|
+
/**
|
|
99
|
+
* Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
|
|
100
|
+
* merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
|
|
101
|
+
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
102
|
+
*/
|
|
103
|
+
setUserContext(partial: Partial<WireUserContext>): void;
|
|
84
104
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
85
105
|
flush(): void;
|
|
86
106
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
@@ -98,13 +118,58 @@ export const createAnalytics = (
|
|
|
98
118
|
config: CreateAnalyticsConfig,
|
|
99
119
|
options: AnalyticsOptions = {},
|
|
100
120
|
): Analytics => {
|
|
101
|
-
|
|
121
|
+
// A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
|
|
122
|
+
// no per-open session has been registered yet (see `resolveSessionId`).
|
|
123
|
+
const instanceSessionId = config.sessionId ?? makeSessionId();
|
|
124
|
+
|
|
125
|
+
// The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
|
|
126
|
+
// id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
|
|
127
|
+
// `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
|
|
128
|
+
// phantom session; finally fall back to the stable per-instance id when no open is registered yet.
|
|
129
|
+
const resolveSessionId = (): string =>
|
|
130
|
+
config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
|
|
102
131
|
|
|
103
|
-
//
|
|
132
|
+
// The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
|
|
133
|
+
// every event so a post-mount update (login) takes effect immediately. Declared before the envelope
|
|
134
|
+
// provider so the provider can read the current `userContext.appVersion` (see below).
|
|
135
|
+
let userContext: WireUserContext = { ...(config.userContext ?? {}) };
|
|
136
|
+
|
|
137
|
+
// Auto device id (the headline: "device" fully automatic). When the host supplies NO `deviceKey`,
|
|
138
|
+
// the kit mints ONE stable, non-PII per-install id, PERSISTS it via the host `storage`, and reuses it
|
|
139
|
+
// on every subsequent open — so `user_context.device_key` is ALWAYS present (the server's
|
|
140
|
+
// review/questionnaire gating + A/B stickiness both key on it) with zero host wiring. A host-supplied
|
|
141
|
+
// `deviceKey` still wins (see `applyContext`). Falls back to an in-memory id (stable for this
|
|
142
|
+
// instance) when no storage is available.
|
|
143
|
+
const hostDeviceKeyAtInit =
|
|
144
|
+
typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
|
|
145
|
+
? config.userContext.deviceKey.trim()
|
|
146
|
+
: undefined;
|
|
147
|
+
// Minted synchronously so `device_key` is never missing, even before the async storage read resolves.
|
|
148
|
+
let autoDeviceKey = mintDeviceId();
|
|
149
|
+
if (config.storage && !hostDeviceKeyAtInit) {
|
|
150
|
+
const storage = config.storage;
|
|
151
|
+
const deviceKey = deviceIdStorageKey(config.appId);
|
|
152
|
+
void storage
|
|
153
|
+
.getItem(deviceKey)
|
|
154
|
+
.then((saved) => {
|
|
155
|
+
const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
|
|
156
|
+
// Reuse the persisted per-install id across opens; on first run persist the freshly minted one.
|
|
157
|
+
if (persisted) autoDeviceKey = persisted;
|
|
158
|
+
else void storage.setItem(deviceKey, autoDeviceKey).catch(() => {});
|
|
159
|
+
})
|
|
160
|
+
.catch(() => {});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A provider (not a fixed value) so `networkType`, the current session id, AND the effective app
|
|
164
|
+
// version are evaluated fresh on every enqueue. An explicit `WireUserContext.appVersion` (a host that
|
|
165
|
+
// set the version ONLY inside `userContext`) now flows into `device.appVersion` too — not just
|
|
166
|
+
// `user_context.app_version` — so the server's `by_app_version` breakdown (which reads
|
|
167
|
+
// `device.appVersion`) agrees. Explicit wins over the auto-detected device version;
|
|
168
|
+
// `buildContextEnvelope` keeps the auto value when neither is set.
|
|
104
169
|
const envelope = (): ContextEnvelope =>
|
|
105
170
|
buildContextEnvelope({
|
|
106
|
-
sessionId,
|
|
107
|
-
appVersion: config.appVersion,
|
|
171
|
+
sessionId: resolveSessionId(),
|
|
172
|
+
appVersion: userContext.appVersion ?? config.appVersion,
|
|
108
173
|
appBuild: config.appBuild,
|
|
109
174
|
networkType: config.networkType,
|
|
110
175
|
});
|
|
@@ -117,28 +182,67 @@ export const createAnalytics = (
|
|
|
117
182
|
...options,
|
|
118
183
|
});
|
|
119
184
|
|
|
120
|
-
// Per-session, in-memory user binding.
|
|
121
|
-
|
|
185
|
+
// Per-session, in-memory user binding. Seeded from the init context, then persisted across
|
|
186
|
+
// launches when storage is provided.
|
|
187
|
+
let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
|
|
122
188
|
const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
|
|
123
189
|
|
|
124
190
|
if (config.storage) {
|
|
125
191
|
void config.storage
|
|
126
192
|
.getItem(storageKey)
|
|
127
193
|
.then((saved) => {
|
|
128
|
-
|
|
194
|
+
// Don't clobber an explicit init-context user id with a stale persisted one.
|
|
195
|
+
if (saved && !boundUserId) boundUserId = saved;
|
|
129
196
|
})
|
|
130
197
|
.catch(() => {});
|
|
131
198
|
}
|
|
132
199
|
|
|
200
|
+
// Stamp the resolved rich context onto an event: the `user_context` bucket (device_key, app_version,
|
|
201
|
+
// opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
|
|
202
|
+
// key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
|
|
203
|
+
const applyContext = (event: ClientEvent): void => {
|
|
204
|
+
// Host `deviceKey` wins; otherwise the auto-minted/persisted per-install id fills it in so
|
|
205
|
+
// `user_context.device_key` is always present.
|
|
206
|
+
const hostDeviceKey =
|
|
207
|
+
typeof userContext.deviceKey === "string" && userContext.deviceKey.trim()
|
|
208
|
+
? userContext.deviceKey
|
|
209
|
+
: undefined;
|
|
210
|
+
const resolved = resolveUserContext(
|
|
211
|
+
{ ...userContext, deviceKey: hostDeviceKey ?? autoDeviceKey },
|
|
212
|
+
{ autoAppVersion: config.appVersion },
|
|
213
|
+
);
|
|
214
|
+
if (resolved.userContext) {
|
|
215
|
+
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
216
|
+
}
|
|
217
|
+
if (boundUserId && !event.user_id) event.user_id = boundUserId;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const setUserContext = (partial: Partial<WireUserContext>): void => {
|
|
221
|
+
if (!partial || typeof partial !== "object") return;
|
|
222
|
+
// Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
|
|
223
|
+
const mergedExtra =
|
|
224
|
+
partial.extra || userContext.extra
|
|
225
|
+
? { ...(userContext.extra ?? {}), ...(partial.extra ?? {}) }
|
|
226
|
+
: undefined;
|
|
227
|
+
userContext = { ...userContext, ...partial };
|
|
228
|
+
if (mergedExtra) userContext.extra = mergedExtra;
|
|
229
|
+
// A user id supplied here binds like `identify` so subsequent events carry `user_id`.
|
|
230
|
+
const uid = sanitizeUserId(partial.userId);
|
|
231
|
+
if (uid) {
|
|
232
|
+
boundUserId = uid;
|
|
233
|
+
if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
133
237
|
const track = (event: string, props?: AnalyticsProps): void => {
|
|
134
238
|
if (!event) return;
|
|
135
239
|
const clientEvent: ClientEvent = {
|
|
136
240
|
event_type: "app_event",
|
|
137
|
-
session_id:
|
|
241
|
+
session_id: resolveSessionId(),
|
|
138
242
|
question_key: event,
|
|
139
243
|
};
|
|
140
244
|
if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
|
|
141
|
-
|
|
245
|
+
applyContext(clientEvent);
|
|
142
246
|
queue.enqueue(clientEvent);
|
|
143
247
|
};
|
|
144
248
|
|
|
@@ -148,11 +252,11 @@ export const createAnalytics = (
|
|
|
148
252
|
const meta = { screen: name, ...(props ?? {}) };
|
|
149
253
|
const clientEvent: ClientEvent = {
|
|
150
254
|
event_type: "app_event",
|
|
151
|
-
session_id:
|
|
255
|
+
session_id: resolveSessionId(),
|
|
152
256
|
question_key: "screen",
|
|
153
257
|
meta: JSON.stringify(meta),
|
|
154
258
|
};
|
|
155
|
-
|
|
259
|
+
applyContext(clientEvent);
|
|
156
260
|
queue.enqueue(clientEvent);
|
|
157
261
|
};
|
|
158
262
|
|
|
@@ -166,10 +270,13 @@ export const createAnalytics = (
|
|
|
166
270
|
}
|
|
167
271
|
const clientEvent: ClientEvent = {
|
|
168
272
|
event_type: "identify",
|
|
169
|
-
|
|
273
|
+
// Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
|
|
274
|
+
// the session it already saw instead of back-filling a phantom `session_started`.
|
|
275
|
+
session_id: resolveSessionId(),
|
|
170
276
|
user_id: clean,
|
|
171
277
|
};
|
|
172
278
|
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
279
|
+
applyContext(clientEvent);
|
|
173
280
|
queue.enqueue(clientEvent);
|
|
174
281
|
};
|
|
175
282
|
|
|
@@ -177,6 +284,7 @@ export const createAnalytics = (
|
|
|
177
284
|
track,
|
|
178
285
|
screen,
|
|
179
286
|
identify,
|
|
287
|
+
setUserContext,
|
|
180
288
|
flush: queue.flush,
|
|
181
289
|
notifyOnline: queue.notifyOnline,
|
|
182
290
|
size: queue.size,
|
|
@@ -30,7 +30,7 @@ export type ContextEnvelope = {
|
|
|
30
30
|
device: DeviceContext;
|
|
31
31
|
/** Correlation id for this app-open / flow (caller-supplied). */
|
|
32
32
|
sessionId?: string;
|
|
33
|
-
/**
|
|
33
|
+
/** App version, e.g. "1.4.2" (mirrors `device.appVersion`; host-injected, else auto-detected). */
|
|
34
34
|
appVersion?: string;
|
|
35
35
|
/** Host native build number, e.g. "412" (from `expo-constants` `nativeBuildVersion`). */
|
|
36
36
|
appBuild?: string;
|
|
@@ -47,9 +47,10 @@ export type ContextEnvelopeInput = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* Build a fresh context envelope. Reuses `collectDeviceContext()` for the device block (which
|
|
51
|
+
* already carries a best-effort auto-detected `appVersion`) and layers the host-injected scalars
|
|
52
|
+
* on top. An explicit `input.appVersion` overrides the auto-detected `device.appVersion`, and the
|
|
53
|
+
* outer `appVersion` scalar mirrors whichever version is effective.
|
|
53
54
|
*
|
|
54
55
|
* Returns a NEW object on every call (no shared mutable reference), so a caller can hold or mutate
|
|
55
56
|
* the result without leaking into the next envelope. Never throws — `collectDeviceContext` is
|
|
@@ -59,12 +60,17 @@ export const buildContextEnvelope = (input: ContextEnvelopeInput = {}): ContextE
|
|
|
59
60
|
// Fresh copy so the returned envelope never aliases a cached device snapshot.
|
|
60
61
|
const device: DeviceContext = { ...collectDeviceContext() };
|
|
61
62
|
|
|
62
|
-
//
|
|
63
|
-
|
|
63
|
+
// `device.appVersion` is auto-detected best-effort by `collectDeviceContext`; an explicit
|
|
64
|
+
// host `input.appVersion` always wins.
|
|
65
|
+
if (input.appVersion) device.appVersion = input.appVersion;
|
|
66
|
+
|
|
67
|
+
// The outer scalar mirrors the effective version (host-supplied, else auto-detected) so the
|
|
68
|
+
// queue can stamp `user_context.app_version` even when the host never passed one.
|
|
69
|
+
const effectiveAppVersion = input.appVersion ?? device.appVersion;
|
|
64
70
|
|
|
65
71
|
const envelope: ContextEnvelope = { device };
|
|
66
72
|
if (input.sessionId) envelope.sessionId = input.sessionId;
|
|
67
|
-
if (
|
|
73
|
+
if (effectiveAppVersion) envelope.appVersion = effectiveAppVersion;
|
|
68
74
|
if (input.appBuild) envelope.appBuild = input.appBuild;
|
|
69
75
|
if (input.networkType) envelope.networkType = input.networkType;
|
|
70
76
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* currentSession — a tiny module-level registry of the CURRENT per-open `session_id`.
|
|
3
|
+
*
|
|
4
|
+
* WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
|
|
5
|
+
* `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
|
|
6
|
+
* post `app.session_started` with it — so the SERVER knows that id. But other client paths
|
|
7
|
+
* (`identify`, host `app_event`s through the analytics façade) used to reference a DIFFERENT id
|
|
8
|
+
* (a frozen per-instance id), which the server had never seen, so it back-filled a synthetic
|
|
9
|
+
* `session_started` — inflating session counts (the Morrow/Myelino "phantom-session" bug).
|
|
10
|
+
*
|
|
11
|
+
* This registry is the single seam that lets those paths reuse the LIVE per-open session id the
|
|
12
|
+
* server already ingested. `reportSessionStart` writes the current id here on every open; the façade
|
|
13
|
+
* reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
|
|
14
|
+
*
|
|
15
|
+
* DEPENDENCY-FREE + PROCESS-LOCAL: a plain module variable. It is intentionally NOT persisted — it
|
|
16
|
+
* tracks the CURRENT process's open, and a fresh open always overwrites it. No cross-launch state.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let _currentSessionId: string | undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
|
|
23
|
+
* app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
|
|
24
|
+
*/
|
|
25
|
+
export const setCurrentSessionId = (id: string | undefined): void => {
|
|
26
|
+
if (typeof id === "string" && id.length > 0) _currentSessionId = id;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
|
|
30
|
+
export const getCurrentSessionId = (): string | undefined => _currentSessionId;
|
|
31
|
+
|
|
32
|
+
/** Test-only: forget the current session id so a unit test starts from a clean registry. */
|
|
33
|
+
export const resetCurrentSessionId = (): void => {
|
|
34
|
+
_currentSessionId = undefined;
|
|
35
|
+
};
|
package/src/analytics/index.ts
CHANGED
|
@@ -57,3 +57,6 @@ export type {
|
|
|
57
57
|
|
|
58
58
|
// ─── The thin optional React hook over the façade ─────────────────────────────
|
|
59
59
|
export { useAnalytics } from "./useAnalytics";
|
|
60
|
+
|
|
61
|
+
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
62
|
+
export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deviceId — mint a stable, NON-PII, per-install device id the kit owns when the host supplies
|
|
3
|
+
* none. This is the headline of "device fully automatic": the analytics façade auto-mints ONE id,
|
|
4
|
+
* persists it via the host's `storage` abstraction, and reuses it on every subsequent open — so
|
|
5
|
+
* `user_context.device_key` is ALWAYS present and the server's review/questionnaire gating +
|
|
6
|
+
* A/B stickiness (both key on `device_key`) work out of the box, with zero host wiring.
|
|
7
|
+
*
|
|
8
|
+
* WHY it is NOT PII and adds NO dependency (the kit's hard rules):
|
|
9
|
+
* The id is a random token generated from `Date.now()` + `Math.random()` — it carries NO hardware
|
|
10
|
+
* identifier, NO IDFA/GAID, NO fingerprint. It is a first-party per-install correlation key, the
|
|
11
|
+
* same privacy category as a first-party cookie: it groups a single install's sessions and cannot
|
|
12
|
+
* identify a person or be joined across apps. There is NO `uuid` (or any) dependency — a
|
|
13
|
+
* time+random scheme is sufficient because the id is minted ONCE and then persisted, so global
|
|
14
|
+
* uniqueness across the fleet is not required (a per-install collision is astronomically unlikely
|
|
15
|
+
* and inconsequential — worst case two installs share a bucket).
|
|
16
|
+
*
|
|
17
|
+
* A host that wants its OWN device id still wins: pass `WireUserContext.deviceKey` and the kit uses
|
|
18
|
+
* that verbatim and never mints/persists an auto id.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
|
|
22
|
+
export const AUTO_DEVICE_ID_PREFIX = "wdev_";
|
|
23
|
+
|
|
24
|
+
/** The storage key the façade persists the auto-minted id under (namespaced per `appId`). */
|
|
25
|
+
export const deviceIdStorageKey = (appId?: string): string =>
|
|
26
|
+
`wireai:analytics:deviceKey:${appId ?? "default"}`;
|
|
27
|
+
|
|
28
|
+
/** One 32-bit base-36 chunk of randomness. Two chunks are concatenated for a wider token. */
|
|
29
|
+
const randomChunk = (): string =>
|
|
30
|
+
Math.floor(Math.random() * 0x100000000)
|
|
31
|
+
.toString(36)
|
|
32
|
+
.padStart(6, "0");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mint a fresh per-install device id. Dependency-free (`Date.now()` + `Math.random()`), never
|
|
36
|
+
* throws, and returns a NEW value on every call — the façade mints ONCE and persists, so this is
|
|
37
|
+
* called at most once per install (then the persisted value is reused). Two random chunks plus the
|
|
38
|
+
* timestamp keep the token wide enough that a per-install collision is not a practical concern.
|
|
39
|
+
*/
|
|
40
|
+
export const mintDeviceId = (): string => {
|
|
41
|
+
const time = Date.now().toString(36);
|
|
42
|
+
return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
|
|
43
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* userContext — the ONE extensible object a host passes once and the kit flows into every
|
|
3
|
+
* analytics event's `user_context` (plus the top-level opaque `user_id`).
|
|
4
|
+
*
|
|
5
|
+
* WHY it exists: hosts already hand the kit fragments of "who this user is" — `config.appVersion`,
|
|
6
|
+
* `useSessionStart({ deviceKey, userId })`, `<WireOnboarding userContext={…} />` — but there was no
|
|
7
|
+
* single object that carries app version + device key + user id + (opt-in) email + arbitrary extras
|
|
8
|
+
* together, with one precedence rule, into every event. `WireUserContext` is that object;
|
|
9
|
+
* `resolveUserContext` is the pure merge that turns it into the wire shape.
|
|
10
|
+
*
|
|
11
|
+
* PRECEDENCE (the one rule): an explicit `WireUserContext` field WINS over the #42 auto-detected
|
|
12
|
+
* `device`/`appVersion`. A missing field is OMITTED, never sent empty.
|
|
13
|
+
*
|
|
14
|
+
* WHERE EACH FIELD LANDS (deliberate separation so nothing leaks across buckets):
|
|
15
|
+
* • `userId` → the event's TOP-LEVEL opaque `user_id` (via `sanitizeUserId`). NEVER the bucket.
|
|
16
|
+
* • `userEmail` → its OWN key `user_context.user_email`. NEVER merged into `userId`. OPT-IN PII.
|
|
17
|
+
* • `deviceKey` → `user_context.device_key` (the server's `_event_device_key` reads it there).
|
|
18
|
+
* • `appVersion`→ `user_context.app_version` (and returned as `appVersion` for `device.appVersion`).
|
|
19
|
+
* • `extra` → NAMESPACED under a `custom.` key prefix, coerced to scalars, so a host extra can
|
|
20
|
+
* never collide with a reserved `user_context` key.
|
|
21
|
+
*
|
|
22
|
+
* DEPENDENCY-FREE: the only import is the kit's own `sanitizeUserId`. The optional email hash is a
|
|
23
|
+
* dependency-free FNV-1a fold (see {@link hashEmailFnv1a}) — no crypto library, no async.
|
|
24
|
+
*/
|
|
25
|
+
import { sanitizeUserId } from "../identity/userIdentity";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The single, extensible user-context object. A host passes it ONCE (at analytics init) and may
|
|
29
|
+
* update it post-mount (e.g. attach `userId`/`userEmail` at login) via `setUserContext(partial)`.
|
|
30
|
+
* Every field is optional; missing fields are omitted from the wire payload.
|
|
31
|
+
*/
|
|
32
|
+
export interface WireUserContext {
|
|
33
|
+
/**
|
|
34
|
+
* Host app version, e.g. "1.4.2". EXPLICIT — wins over the #42 auto-detected `device.appVersion`.
|
|
35
|
+
* Lands in `user_context.app_version`. Omitted when neither this nor auto-detect yields a version.
|
|
36
|
+
*/
|
|
37
|
+
appVersion?: string;
|
|
38
|
+
/**
|
|
39
|
+
* A stable, non-PII device id the host owns. Lands in `user_context.device_key` (NOT `session_id`),
|
|
40
|
+
* where the server groups a device's sessions. Host-supplied; the kit never mints or reads one.
|
|
41
|
+
*/
|
|
42
|
+
deviceKey?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The host's OPAQUE PSEUDONYMOUS user id (their internal id — NOT an email/name/phone). Sanitized +
|
|
45
|
+
* capped (see `sanitizeUserId`) and placed on the event's top-level `user_id`. NEVER the bucket.
|
|
46
|
+
*/
|
|
47
|
+
userId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* OPT-IN PII. The user's email, its OWN field (`user_context.user_email`) — NEVER merged into
|
|
50
|
+
* `userId`. The kit NEVER auto-collects this; a host passes it only WITH the user's consent (EU
|
|
51
|
+
* users: treat as personal data). For a non-reversible form, set {@link hashEmail} `true` (the kit
|
|
52
|
+
* folds it with a dependency-free hash and stamps `user_context.user_email_hashed: true`), OR
|
|
53
|
+
* pre-hash host-side with a cryptographic digest and pass that here with `hashEmail` falsy.
|
|
54
|
+
*/
|
|
55
|
+
userEmail?: string;
|
|
56
|
+
/**
|
|
57
|
+
* When `true`, {@link userEmail} is folded with the kit's dependency-free {@link hashEmailFnv1a}
|
|
58
|
+
* before it leaves the device, and `user_context.user_email_hashed` is set `true`. NOTE: FNV-1a is
|
|
59
|
+
* a lightweight NON-cryptographic fold (obfuscation, not a secure digest). For a cryptographic
|
|
60
|
+
* hash, compute it host-side (e.g. SHA-256 via `expo-crypto`) and pass the digest as `userEmail`
|
|
61
|
+
* with `hashEmail` falsy. Default: raw email is sent as-is (opt-in already gated it upstream).
|
|
62
|
+
*/
|
|
63
|
+
hashEmail?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Arbitrary host context (signup method, referral, plan tier…). Each value is coerced to a scalar
|
|
66
|
+
* (`string | number | boolean`; non-scalars and non-finite numbers are DROPPED) and NAMESPACED
|
|
67
|
+
* under a `custom.` key prefix in `user_context` (e.g. `user_context["custom.referral"]`) so it can
|
|
68
|
+
* never collide with a reserved key. No raw PII — use {@link userEmail} for email.
|
|
69
|
+
*/
|
|
70
|
+
extra?: Record<string, string | number | boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The wire-shaped result of {@link resolveUserContext}. `userContext` is the non-PII/opt-in-PII
|
|
75
|
+
* bucket stamped onto the event; `userId` is the top-level opaque id; `appVersion`/`deviceKey` are
|
|
76
|
+
* echoed for callers that also place them elsewhere (e.g. `device.appVersion`). Absent fields are
|
|
77
|
+
* omitted so a caller can spread this without sending empties.
|
|
78
|
+
*/
|
|
79
|
+
export interface ResolvedUserContext {
|
|
80
|
+
/** The opaque, sanitized user id → the event's top-level `user_id`. Omitted when unset/blank. */
|
|
81
|
+
userId?: string;
|
|
82
|
+
/** The stable device id → `user_context.device_key`. Omitted when unset. */
|
|
83
|
+
deviceKey?: string;
|
|
84
|
+
/** The effective app version (explicit > auto-detected) → `user_context.app_version`. */
|
|
85
|
+
appVersion?: string;
|
|
86
|
+
/** The `user_context` bucket (device_key, app_version, user_email[+ _hashed], custom.*). */
|
|
87
|
+
userContext?: Record<string, string | number | boolean>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
|
|
91
|
+
export const RESERVED_USER_CONTEXT_KEYS = [
|
|
92
|
+
"device_key",
|
|
93
|
+
"app_version",
|
|
94
|
+
"app_build",
|
|
95
|
+
"network_type",
|
|
96
|
+
"session_count",
|
|
97
|
+
"returning",
|
|
98
|
+
"platform",
|
|
99
|
+
"user_email",
|
|
100
|
+
"user_email_hashed",
|
|
101
|
+
] as const;
|
|
102
|
+
|
|
103
|
+
/** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
|
|
104
|
+
export const EXTRA_KEY_PREFIX = "custom." as const;
|
|
105
|
+
|
|
106
|
+
/** A finite scalar the wire accepts. Non-finite numbers (NaN/Infinity) are NOT scalars here. */
|
|
107
|
+
export const isWireScalar = (value: unknown): value is string | number | boolean => {
|
|
108
|
+
const t = typeof value;
|
|
109
|
+
if (t === "string" || t === "boolean") return true;
|
|
110
|
+
if (t === "number") return Number.isFinite(value as number);
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fold an email to a stable, dependency-free 32-bit FNV-1a hex token (lowercased + trimmed first so
|
|
116
|
+
* the same address always folds identically). This is OBFUSCATION, not a cryptographic digest — it
|
|
117
|
+
* is not collision-resistant. For a real hash, pre-hash host-side and pass the digest as `userEmail`.
|
|
118
|
+
*/
|
|
119
|
+
export const hashEmailFnv1a = (email: string): string => {
|
|
120
|
+
const normalized = email.trim().toLowerCase();
|
|
121
|
+
let hash = 0x811c9dc5; // FNV offset basis (32-bit)
|
|
122
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
123
|
+
hash ^= normalized.charCodeAt(i);
|
|
124
|
+
hash = Math.imul(hash, 0x01000193); // FNV prime (32-bit), kept in 32-bit via imul
|
|
125
|
+
}
|
|
126
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
|
|
130
|
+
const cleanString = (value: unknown): string | undefined => {
|
|
131
|
+
if (typeof value !== "string") return undefined;
|
|
132
|
+
const trimmed = value.trim();
|
|
133
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Coerce a host `extra` map into the namespaced, scalar-only bucket shape. Every kept value is
|
|
138
|
+
* placed under `custom.<key>`; non-scalar values (objects, arrays, null, functions, NaN/Infinity)
|
|
139
|
+
* are DROPPED. Returns an object (possibly empty).
|
|
140
|
+
*/
|
|
141
|
+
export const namespaceExtra = (
|
|
142
|
+
extra: Record<string, unknown> | undefined,
|
|
143
|
+
): Record<string, string | number | boolean> => {
|
|
144
|
+
const out: Record<string, string | number | boolean> = {};
|
|
145
|
+
if (!extra || typeof extra !== "object") return out;
|
|
146
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
147
|
+
const cleanKey = cleanString(key);
|
|
148
|
+
if (!cleanKey) continue;
|
|
149
|
+
if (!isWireScalar(value)) continue; // drop anything that isn't a finite scalar
|
|
150
|
+
out[`${EXTRA_KEY_PREFIX}${cleanKey}`] = value;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Options for {@link resolveUserContext}. */
|
|
156
|
+
export interface ResolveUserContextOptions {
|
|
157
|
+
/**
|
|
158
|
+
* The kit's best-effort auto-detected app version (#42; from `detectAppVersion()`/the device
|
|
159
|
+
* snapshot). Used ONLY when the explicit `WireUserContext.appVersion` is absent — explicit wins.
|
|
160
|
+
*/
|
|
161
|
+
autoAppVersion?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Merge a {@link WireUserContext} into the wire shape with the precedence rule (explicit field >
|
|
166
|
+
* auto-detected). Pure, never throws. Missing fields are omitted so the result can be spread onto an
|
|
167
|
+
* event without sending empties.
|
|
168
|
+
*/
|
|
169
|
+
export const resolveUserContext = (
|
|
170
|
+
ctx: WireUserContext = {},
|
|
171
|
+
opts: ResolveUserContextOptions = {},
|
|
172
|
+
): ResolvedUserContext => {
|
|
173
|
+
const result: ResolvedUserContext = {};
|
|
174
|
+
const bucket: Record<string, string | number | boolean> = {};
|
|
175
|
+
|
|
176
|
+
// userId → top-level opaque id (NEVER the bucket). Sanitized + capped host-side.
|
|
177
|
+
const userId = sanitizeUserId(ctx.userId);
|
|
178
|
+
if (userId) result.userId = userId;
|
|
179
|
+
|
|
180
|
+
// deviceKey → user_context.device_key (NOT session_id).
|
|
181
|
+
const deviceKey = cleanString(ctx.deviceKey);
|
|
182
|
+
if (deviceKey) {
|
|
183
|
+
result.deviceKey = deviceKey;
|
|
184
|
+
bucket.device_key = deviceKey;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// appVersion → explicit wins over auto-detected (#42); echoed for device.appVersion callers.
|
|
188
|
+
const appVersion = cleanString(ctx.appVersion) ?? cleanString(opts.autoAppVersion);
|
|
189
|
+
if (appVersion) {
|
|
190
|
+
result.appVersion = appVersion;
|
|
191
|
+
bucket.app_version = appVersion;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// userEmail → its OWN key. OPT-IN PII, optionally folded. NEVER touches userId.
|
|
195
|
+
const email = cleanString(ctx.userEmail);
|
|
196
|
+
if (email) {
|
|
197
|
+
if (ctx.hashEmail) {
|
|
198
|
+
bucket.user_email = hashEmailFnv1a(email);
|
|
199
|
+
bucket.user_email_hashed = true;
|
|
200
|
+
} else {
|
|
201
|
+
bucket.user_email = email;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// extra → namespaced + scalar-coerced.
|
|
206
|
+
Object.assign(bucket, namespaceExtra(ctx.extra));
|
|
207
|
+
|
|
208
|
+
if (Object.keys(bucket).length > 0) result.userContext = bucket;
|
|
209
|
+
return result;
|
|
210
|
+
};
|