@wireai/activation 0.4.0 → 0.7.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 +57 -0
- package/dist/analytics/index.d.mts +15 -2
- package/dist/analytics/index.d.ts +15 -2
- package/dist/analytics/index.js +181 -13
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +179 -14
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{eventQueue-CA1d8Fmn.d.mts → currentSession-BJBB7i4-.d.mts} +143 -15
- package/dist/{eventQueue-CrNB9gzH.d.ts → currentSession-CxnP7gAa.d.ts} +143 -15
- package/dist/index.d.mts +37 -37
- package/dist/index.d.ts +37 -37
- package/dist/index.js +158 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +149 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +4 -3
- package/src/analytics/analyticsFacade.ts +74 -11
- package/src/analytics/contextEnvelope.ts +13 -7
- package/src/analytics/currentSession.ts +35 -0
- package/src/analytics/index.ts +3 -0
- package/src/context/userContext.ts +210 -0
- package/src/device/appVersion.ts +103 -0
- package/src/device/deviceContext.ts +17 -5
- package/src/identity/userIdentity.ts +5 -0
- package/src/index.ts +23 -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.7.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,10 @@
|
|
|
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";
|
|
32
34
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
33
35
|
|
|
34
36
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
@@ -62,6 +64,13 @@ export type CreateAnalyticsConfig = {
|
|
|
62
64
|
appBuild?: string;
|
|
63
65
|
/** Host connectivity signal, e.g. "wifi" | "cellular" — read fresh per event via the provider. */
|
|
64
66
|
networkType?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
|
|
69
|
+
* user id, opt-in email, arbitrary `extra`). Passed ONCE here at init; updatable post-mount via
|
|
70
|
+
* {@link Analytics.setUserContext} (e.g. attach `userId`/`userEmail` at login). Optional — omit it
|
|
71
|
+
* and events carry only the device context, exactly as before.
|
|
72
|
+
*/
|
|
73
|
+
userContext?: WireUserContext;
|
|
65
74
|
};
|
|
66
75
|
|
|
67
76
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
@@ -81,6 +90,12 @@ export type Analytics = {
|
|
|
81
90
|
screen(name: string, props?: AnalyticsProps): void;
|
|
82
91
|
/** Bind the host's opaque user id (per-session, in-memory) and emit an `identify` event. */
|
|
83
92
|
identify(userId: string, traits?: AnalyticsProps): void;
|
|
93
|
+
/**
|
|
94
|
+
* Update the {@link WireUserContext} after init (e.g. attach `userId`/`userEmail` at login). Shallow
|
|
95
|
+
* merges the partial over the current context (`extra` is deep-merged); a supplied `userId` also
|
|
96
|
+
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
97
|
+
*/
|
|
98
|
+
setUserContext(partial: Partial<WireUserContext>): void;
|
|
84
99
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
85
100
|
flush(): void;
|
|
86
101
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
@@ -98,12 +113,22 @@ export const createAnalytics = (
|
|
|
98
113
|
config: CreateAnalyticsConfig,
|
|
99
114
|
options: AnalyticsOptions = {},
|
|
100
115
|
): Analytics => {
|
|
101
|
-
|
|
116
|
+
// A STABLE per-instance fallback id, used only when no explicit `config.sessionId` was given AND
|
|
117
|
+
// no per-open session has been registered yet (see `resolveSessionId`).
|
|
118
|
+
const instanceSessionId = config.sessionId ?? makeSessionId();
|
|
119
|
+
|
|
120
|
+
// The session id every event correlates to. Precedence: an explicit `config.sessionId` freezes the
|
|
121
|
+
// id (opt-out of the reuse); otherwise reuse the LIVE per-open session the server saw (via
|
|
122
|
+
// `app.session_started`) so `identify`/app-events don't mint a fresh id the server back-fills into a
|
|
123
|
+
// phantom session; finally fall back to the stable per-instance id when no open is registered yet.
|
|
124
|
+
const resolveSessionId = (): string =>
|
|
125
|
+
config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
|
|
102
126
|
|
|
103
|
-
// A provider (not a fixed value) so `networkType`
|
|
127
|
+
// A provider (not a fixed value) so `networkType` + the current session id are evaluated fresh
|
|
128
|
+
// on every enqueue.
|
|
104
129
|
const envelope = (): ContextEnvelope =>
|
|
105
130
|
buildContextEnvelope({
|
|
106
|
-
sessionId,
|
|
131
|
+
sessionId: resolveSessionId(),
|
|
107
132
|
appVersion: config.appVersion,
|
|
108
133
|
appBuild: config.appBuild,
|
|
109
134
|
networkType: config.networkType,
|
|
@@ -117,28 +142,62 @@ export const createAnalytics = (
|
|
|
117
142
|
...options,
|
|
118
143
|
});
|
|
119
144
|
|
|
120
|
-
//
|
|
121
|
-
|
|
145
|
+
// The mutable rich user-context: seeded at init, updated via `setUserContext`. Resolved fresh on
|
|
146
|
+
// every event so a post-mount update (login) takes effect immediately.
|
|
147
|
+
let userContext: WireUserContext = { ...(config.userContext ?? {}) };
|
|
148
|
+
|
|
149
|
+
// Per-session, in-memory user binding. Seeded from the init context, then persisted across
|
|
150
|
+
// launches when storage is provided.
|
|
151
|
+
let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
|
|
122
152
|
const storageKey = `wireai:analytics:userId:${config.appId ?? "default"}`;
|
|
123
153
|
|
|
124
154
|
if (config.storage) {
|
|
125
155
|
void config.storage
|
|
126
156
|
.getItem(storageKey)
|
|
127
157
|
.then((saved) => {
|
|
128
|
-
|
|
158
|
+
// Don't clobber an explicit init-context user id with a stale persisted one.
|
|
159
|
+
if (saved && !boundUserId) boundUserId = saved;
|
|
129
160
|
})
|
|
130
161
|
.catch(() => {});
|
|
131
162
|
}
|
|
132
163
|
|
|
164
|
+
// Stamp the resolved rich context onto an event: the `user_context` bucket (device_key, app_version,
|
|
165
|
+
// opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
|
|
166
|
+
// key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
|
|
167
|
+
const applyContext = (event: ClientEvent): void => {
|
|
168
|
+
const resolved = resolveUserContext(userContext, { autoAppVersion: config.appVersion });
|
|
169
|
+
if (resolved.userContext) {
|
|
170
|
+
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
171
|
+
}
|
|
172
|
+
if (boundUserId && !event.user_id) event.user_id = boundUserId;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const setUserContext = (partial: Partial<WireUserContext>): void => {
|
|
176
|
+
if (!partial || typeof partial !== "object") return;
|
|
177
|
+
// Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
|
|
178
|
+
const mergedExtra =
|
|
179
|
+
partial.extra || userContext.extra
|
|
180
|
+
? { ...(userContext.extra ?? {}), ...(partial.extra ?? {}) }
|
|
181
|
+
: undefined;
|
|
182
|
+
userContext = { ...userContext, ...partial };
|
|
183
|
+
if (mergedExtra) userContext.extra = mergedExtra;
|
|
184
|
+
// A user id supplied here binds like `identify` so subsequent events carry `user_id`.
|
|
185
|
+
const uid = sanitizeUserId(partial.userId);
|
|
186
|
+
if (uid) {
|
|
187
|
+
boundUserId = uid;
|
|
188
|
+
if (config.storage) void config.storage.setItem(storageKey, uid).catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
133
192
|
const track = (event: string, props?: AnalyticsProps): void => {
|
|
134
193
|
if (!event) return;
|
|
135
194
|
const clientEvent: ClientEvent = {
|
|
136
195
|
event_type: "app_event",
|
|
137
|
-
session_id:
|
|
196
|
+
session_id: resolveSessionId(),
|
|
138
197
|
question_key: event,
|
|
139
198
|
};
|
|
140
199
|
if (props && Object.keys(props).length > 0) clientEvent.meta = JSON.stringify(props);
|
|
141
|
-
|
|
200
|
+
applyContext(clientEvent);
|
|
142
201
|
queue.enqueue(clientEvent);
|
|
143
202
|
};
|
|
144
203
|
|
|
@@ -148,11 +207,11 @@ export const createAnalytics = (
|
|
|
148
207
|
const meta = { screen: name, ...(props ?? {}) };
|
|
149
208
|
const clientEvent: ClientEvent = {
|
|
150
209
|
event_type: "app_event",
|
|
151
|
-
session_id:
|
|
210
|
+
session_id: resolveSessionId(),
|
|
152
211
|
question_key: "screen",
|
|
153
212
|
meta: JSON.stringify(meta),
|
|
154
213
|
};
|
|
155
|
-
|
|
214
|
+
applyContext(clientEvent);
|
|
156
215
|
queue.enqueue(clientEvent);
|
|
157
216
|
};
|
|
158
217
|
|
|
@@ -166,10 +225,13 @@ export const createAnalytics = (
|
|
|
166
225
|
}
|
|
167
226
|
const clientEvent: ClientEvent = {
|
|
168
227
|
event_type: "identify",
|
|
169
|
-
|
|
228
|
+
// Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
|
|
229
|
+
// the session it already saw instead of back-filling a phantom `session_started`.
|
|
230
|
+
session_id: resolveSessionId(),
|
|
170
231
|
user_id: clean,
|
|
171
232
|
};
|
|
172
233
|
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
234
|
+
applyContext(clientEvent);
|
|
173
235
|
queue.enqueue(clientEvent);
|
|
174
236
|
};
|
|
175
237
|
|
|
@@ -177,6 +239,7 @@ export const createAnalytics = (
|
|
|
177
239
|
track,
|
|
178
240
|
screen,
|
|
179
241
|
identify,
|
|
242
|
+
setUserContext,
|
|
180
243
|
flush: queue.flush,
|
|
181
244
|
notifyOnline: queue.notifyOnline,
|
|
182
245
|
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,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
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* appVersion — best-effort, DEPENDENCY-FREE auto-detection of the host app's version string.
|
|
3
|
+
*
|
|
4
|
+
* WHY this exists: analytics segments the funnel `by_app_version`, but that breakdown is only
|
|
5
|
+
* populated when a `device.appVersion` rides the event. `config.appVersion` (see types.ts) has
|
|
6
|
+
* always been the way to supply it — but it is easy for a host to forget, and then the release
|
|
7
|
+
* breakdown is silently empty. This module fills that gap: when the host does NOT pass a version,
|
|
8
|
+
* the kit makes a best-effort read of the app version the host already ships in its Expo config,
|
|
9
|
+
* so the breakdown works out of the box. An explicit `config.appVersion` always WINS over this.
|
|
10
|
+
*
|
|
11
|
+
* WHY it adds NO dependency (the kit's hard rule): `expo-constants` / `expo-application` are read
|
|
12
|
+
* through a GUARDED, VARIABLE-specifier `require`. Passing a variable (not a string literal) keeps
|
|
13
|
+
* Metro/esbuild from statically resolving the module, so a host that does NOT have it installed
|
|
14
|
+
* (e.g. bare React Native) never fails to bundle — the require simply throws at runtime and is
|
|
15
|
+
* swallowed. Nothing is added to `package.json`; nothing is forced on the host.
|
|
16
|
+
*
|
|
17
|
+
* PRIVACY: an app version string is not PII and identifies no user or device, so surfacing it
|
|
18
|
+
* changes no App Privacy / Data Safety declaration (same guarantee as the rest of deviceContext).
|
|
19
|
+
*
|
|
20
|
+
* NEVER THROWS: every read is guarded; a missing/odd value yields `undefined`, never an exception.
|
|
21
|
+
* Analytics must never be able to break onboarding.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime. Declared locally so
|
|
25
|
+
// this type-checks without ambient Node types; the `typeof` guard keeps the reference ESM-safe.
|
|
26
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
27
|
+
|
|
28
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded runtime require. */
|
|
29
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
30
|
+
|
|
31
|
+
/** Trim + reject non-strings/empties so we only ever emit a real version string. */
|
|
32
|
+
export const coerceVersion = (value: unknown): string | undefined => {
|
|
33
|
+
if (typeof value !== "string") return undefined;
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Guarded runtime require. `moduleName` is a VARIABLE (a parameter), so bundlers cannot statically
|
|
40
|
+
* resolve it — a host without the module never fails to build; the call just throws and is caught.
|
|
41
|
+
*/
|
|
42
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
43
|
+
try {
|
|
44
|
+
if (typeof require !== "function") return undefined;
|
|
45
|
+
return require(moduleName);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Read a module's `default` (Expo modules are consumed as default exports) or the namespace. */
|
|
52
|
+
const interop = (mod: unknown): Record<string, unknown> | undefined => {
|
|
53
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
54
|
+
const def = (mod as { default?: unknown }).default;
|
|
55
|
+
if (def && typeof def === "object") return def as Record<string, unknown>;
|
|
56
|
+
return mod as Record<string, unknown>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Resolve a module namespace, swallowing a throwing require (an uninstalled module throws). */
|
|
60
|
+
const safeInterop = (
|
|
61
|
+
requireModule: OptionalRequire,
|
|
62
|
+
moduleName: string,
|
|
63
|
+
): Record<string, unknown> | undefined => {
|
|
64
|
+
try {
|
|
65
|
+
return interop(requireModule(moduleName));
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect the host app version, preferring `expo-constants` (`expoConfig.version`, then
|
|
73
|
+
* `nativeAppVersion`) and finally `expo-application` (`nativeApplicationVersion`). Returns the
|
|
74
|
+
* first real string, or `undefined` when none of those are available. Pure and never throws.
|
|
75
|
+
*
|
|
76
|
+
* `requireModule` is injectable so tests can exercise the "found" path without the native modules;
|
|
77
|
+
* production defaults to the guarded runtime require above.
|
|
78
|
+
*/
|
|
79
|
+
export const detectAppVersion = (
|
|
80
|
+
requireModule: OptionalRequire = runtimeRequire,
|
|
81
|
+
): string | undefined => {
|
|
82
|
+
try {
|
|
83
|
+
const constants = safeInterop(requireModule, "expo-constants");
|
|
84
|
+
if (constants) {
|
|
85
|
+
const expoConfig = constants.expoConfig;
|
|
86
|
+
if (expoConfig && typeof expoConfig === "object") {
|
|
87
|
+
const fromExpoConfig = coerceVersion((expoConfig as { version?: unknown }).version);
|
|
88
|
+
if (fromExpoConfig) return fromExpoConfig;
|
|
89
|
+
}
|
|
90
|
+
const fromNative = coerceVersion(constants.nativeAppVersion);
|
|
91
|
+
if (fromNative) return fromNative;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const application = safeInterop(requireModule, "expo-application");
|
|
95
|
+
if (application) {
|
|
96
|
+
const fromApplication = coerceVersion(application.nativeApplicationVersion);
|
|
97
|
+
if (fromApplication) return fromApplication;
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Any unexpected read error → "unknown"; analytics must never crash onboarding.
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* adding a single dependency to the kit or changing a host app's App Privacy / Data Safety
|
|
5
5
|
* declarations.
|
|
6
6
|
*
|
|
7
|
-
* HARD RULE (why this file
|
|
7
|
+
* HARD RULE (why this file adds no dependency):
|
|
8
8
|
* The kit stays dependency-free. Everything here comes from `Platform`, `Dimensions`,
|
|
9
|
-
* `I18nManager`, and the standard `Intl` global
|
|
9
|
+
* `I18nManager`, and the standard `Intl` global — plus a best-effort `appVersion` read via
|
|
10
|
+
* `detectAppVersion()`, which itself adds NO dependency (it reaches for `expo-constants` /
|
|
11
|
+
* `expo-application` through a guarded, variable-specifier require that a host without them
|
|
12
|
+
* simply never resolves — see device/appVersion.ts). There are NO advertising IDs, NO
|
|
10
13
|
* `getUniqueId`/IDFA/GAID/fingerprinting APIs, and nothing that would require a new
|
|
11
14
|
* privacy-label entry. A host can adopt this without touching its store declarations.
|
|
12
15
|
*
|
|
@@ -17,6 +20,8 @@
|
|
|
17
20
|
*/
|
|
18
21
|
import { Dimensions, I18nManager, Platform } from "react-native";
|
|
19
22
|
|
|
23
|
+
import { detectAppVersion } from "./appVersion";
|
|
24
|
+
|
|
20
25
|
/** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
|
|
21
26
|
export type DeviceFormFactor = "phone" | "tablet";
|
|
22
27
|
|
|
@@ -50,9 +55,11 @@ export type DeviceContext = {
|
|
|
50
55
|
/** IANA time zone (e.g. "Europe/Berlin"), from `Intl` when available. */
|
|
51
56
|
timeZone?: string;
|
|
52
57
|
/**
|
|
53
|
-
* Host app version (e.g. "1.4.2).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
58
|
+
* Host app version (e.g. "1.4.2"). BEST-EFFORT auto-detected here via `detectAppVersion()`
|
|
59
|
+
* (reads `expo-constants` / `expo-application` when present; adds no dependency — see
|
|
60
|
+
* device/appVersion.ts). An explicit host-injected `config.appVersion` always WINS: the merge
|
|
61
|
+
* sites (`WireOnboarding`, the session-analytics hooks, the context envelope) overwrite this
|
|
62
|
+
* with the host value when one is supplied. Omitted when neither source yields a version.
|
|
56
63
|
*/
|
|
57
64
|
appVersion?: string;
|
|
58
65
|
};
|
|
@@ -154,5 +161,10 @@ export const collectDeviceContext = (): DeviceContext => {
|
|
|
154
161
|
// Intl unavailable — omit locale/timeZone.
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
// Best-effort host app version (adds no dependency; omitted when unavailable). An explicit
|
|
165
|
+
// `config.appVersion` overrides this downstream at the merge sites.
|
|
166
|
+
const appVersion = detectAppVersion();
|
|
167
|
+
if (appVersion) ctx.appVersion = appVersion;
|
|
168
|
+
|
|
157
169
|
return ctx;
|
|
158
170
|
};
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* ⚠️ NO PII. Pass an opaque id (or a hash), never a raw email/name/phone. The id is capped at
|
|
21
21
|
* {@link USER_ID_MAX_LENGTH} chars (longer ids are truncated, not rejected).
|
|
22
22
|
*/
|
|
23
|
+
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
23
24
|
import { reportClientEvent } from "../analytics/reportClientEvent";
|
|
24
25
|
import {
|
|
25
26
|
peekPersistedSession,
|
|
@@ -90,6 +91,10 @@ export const identifyOnboarding = async (
|
|
|
90
91
|
contextId = stored?.id;
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
// Last resort: bind to the LIVE per-open session (registered by `reportSessionStart`) so a
|
|
95
|
+
// post-flow identify with no captured contextId still attaches to a session the server saw,
|
|
96
|
+
// instead of no-oping. The onboarding contextId (above) is still preferred when available.
|
|
97
|
+
if (!contextId) contextId = getCurrentSessionId();
|
|
93
98
|
if (!contextId) return false;
|
|
94
99
|
|
|
95
100
|
reportClientEvent(
|