@wireai/activation 0.7.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/CHANGELOG.md +35 -0
- package/dist/analytics/index.d.mts +8 -4
- package/dist/analytics/index.d.ts +8 -4
- package/dist/analytics/index.js +83 -11
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +83 -11
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BJBB7i4-.d.mts → currentSession-d9CrBxwe.d.mts} +9 -1
- package/dist/{currentSession-CxnP7gAa.d.ts → currentSession-f7LWcdWG.d.ts} +9 -1
- package/dist/index.d.mts +69 -5
- package/dist/index.d.ts +69 -5
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/analytics/analyticsFacade.ts +55 -10
- package/src/context/deviceId.ts +43 -0
- package/src/device/deviceContext.ts +14 -1
- package/src/device/deviceModel.ts +93 -0
- package/src/index.ts +2 -0
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>",
|
|
@@ -31,6 +31,7 @@ import { getCurrentSessionId } from "./currentSession";
|
|
|
31
31
|
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
32
32
|
import { makeSessionId, type ClientEvent } from "./reportClientEvent";
|
|
33
33
|
import { resolveUserContext, type WireUserContext } from "../context/userContext";
|
|
34
|
+
import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
|
|
34
35
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
35
36
|
|
|
36
37
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
@@ -67,8 +68,12 @@ export type CreateAnalyticsConfig = {
|
|
|
67
68
|
/**
|
|
68
69
|
* The rich {@link WireUserContext} to stamp onto every event's `user_context` (device key, opaque
|
|
69
70
|
* 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
|
|
71
|
-
*
|
|
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).
|
|
72
77
|
*/
|
|
73
78
|
userContext?: WireUserContext;
|
|
74
79
|
};
|
|
@@ -124,12 +129,47 @@ export const createAnalytics = (
|
|
|
124
129
|
const resolveSessionId = (): string =>
|
|
125
130
|
config.sessionId ?? getCurrentSessionId() ?? instanceSessionId;
|
|
126
131
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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.
|
|
129
169
|
const envelope = (): ContextEnvelope =>
|
|
130
170
|
buildContextEnvelope({
|
|
131
171
|
sessionId: resolveSessionId(),
|
|
132
|
-
appVersion: config.appVersion,
|
|
172
|
+
appVersion: userContext.appVersion ?? config.appVersion,
|
|
133
173
|
appBuild: config.appBuild,
|
|
134
174
|
networkType: config.networkType,
|
|
135
175
|
});
|
|
@@ -142,10 +182,6 @@ export const createAnalytics = (
|
|
|
142
182
|
...options,
|
|
143
183
|
});
|
|
144
184
|
|
|
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
185
|
// Per-session, in-memory user binding. Seeded from the init context, then persisted across
|
|
150
186
|
// launches when storage is provided.
|
|
151
187
|
let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
|
|
@@ -165,7 +201,16 @@ export const createAnalytics = (
|
|
|
165
201
|
// opt-in user_email, namespaced `custom.*`) and the top-level opaque `user_id`. Never overwrites a
|
|
166
202
|
// key the caller already set (so `identify`'s explicit `user_id` and any caller `user_context` win).
|
|
167
203
|
const applyContext = (event: ClientEvent): void => {
|
|
168
|
-
|
|
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
|
+
);
|
|
169
214
|
if (resolved.userContext) {
|
|
170
215
|
event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
|
|
171
216
|
}
|
|
@@ -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
|
+
};
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { Dimensions, I18nManager, Platform } from "react-native";
|
|
22
22
|
|
|
23
23
|
import { detectAppVersion } from "./appVersion";
|
|
24
|
+
import { detectNativeModel } from "./deviceModel";
|
|
24
25
|
|
|
25
26
|
/** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
|
|
26
27
|
export type DeviceFormFactor = "phone" | "tablet";
|
|
@@ -36,7 +37,15 @@ export type DeviceContext = {
|
|
|
36
37
|
osVersion?: string;
|
|
37
38
|
/** Android device brand (e.g. "samsung"). Android only. */
|
|
38
39
|
brand?: string;
|
|
39
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Device model. On Android it is read directly from `Platform.constants.Model` (e.g. "SM-G991B").
|
|
42
|
+
* iOS `Platform.constants` exposes NO model, so on iOS it is a BEST-EFFORT read of `expo-device`'s
|
|
43
|
+
* `modelName` ("iPhone 14 Pro"), falling back to `modelId` ("iPhone15,2") — dependency-free via a
|
|
44
|
+
* guarded require (see device/deviceModel.ts). ASYMMETRY: without `expo-device` installed, iOS
|
|
45
|
+
* `model` is omitted (there is no dependency-free iOS model in the already-used RN surface, and the
|
|
46
|
+
* kit will not add a native dep for it); Android needs no extra module. Not PII (a device class,
|
|
47
|
+
* not a unique id), so it changes no privacy-label declaration.
|
|
48
|
+
*/
|
|
40
49
|
model?: string;
|
|
41
50
|
/** iOS interface idiom ("phone" | "pad" | …), when reported. iOS only. */
|
|
42
51
|
interfaceIdiom?: string;
|
|
@@ -125,6 +134,10 @@ export const collectDeviceContext = (): DeviceContext => {
|
|
|
125
134
|
ctx.interfaceIdiom = idiom;
|
|
126
135
|
iosIdiom = idiom;
|
|
127
136
|
}
|
|
137
|
+
// iOS has no model in `Platform.constants`; best-effort via `expo-device` (adds no dep,
|
|
138
|
+
// omitted when the module is absent — see device/deviceModel.ts).
|
|
139
|
+
const iosModel = detectNativeModel();
|
|
140
|
+
if (iosModel) ctx.model = iosModel;
|
|
128
141
|
}
|
|
129
142
|
} catch {
|
|
130
143
|
// ignore per-OS constant reads
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deviceModel — best-effort, DEPENDENCY-FREE detection of the device MODEL on iOS.
|
|
3
|
+
*
|
|
4
|
+
* WHY this exists (the asymmetry it closes): `collectDeviceContext` already reports `device.model`
|
|
5
|
+
* on Android straight from `Platform.constants.Model` (e.g. "SM-G991B"), but iOS `Platform.constants`
|
|
6
|
+
* exposes NO model — only `osVersion` / `interfaceIdiom`. So the analytics `by_model` breakdown was
|
|
7
|
+
* Android-only. This module fills the iOS gap with the SAME zero-dependency technique the kit already
|
|
8
|
+
* uses for `appVersion` (see device/appVersion.ts): a GUARDED, VARIABLE-specifier `require` of the
|
|
9
|
+
* common Expo `expo-device` module. If the host has it, iOS gets a model out of the box; if not, the
|
|
10
|
+
* require simply throws and is swallowed and iOS `model` stays omitted — nothing is forced, nothing
|
|
11
|
+
* is added to `package.json`, and a host without `expo-device` never fails to bundle.
|
|
12
|
+
*
|
|
13
|
+
* WHY it is NOT PII: `expo-device`'s `modelName` ("iPhone 14 Pro") and `modelId` ("iPhone15,2") are a
|
|
14
|
+
* device CLASS shared by millions of units — the same privacy category as the Android `Model` the kit
|
|
15
|
+
* already sends. It is NOT a unique device id / IDFA / fingerprint, so surfacing it changes no App
|
|
16
|
+
* Privacy / Data Safety declaration (identical guarantee to the rest of deviceContext). It deliberately
|
|
17
|
+
* does NOT read `expo-device`'s `deviceName` (that is the user-set name, e.g. "Malik's iPhone", and IS
|
|
18
|
+
* personal data).
|
|
19
|
+
*
|
|
20
|
+
* NEVER THROWS: every read is guarded; a missing/odd value yields `undefined`, never an exception.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime. Declared locally so
|
|
24
|
+
// this type-checks without ambient Node types; the `typeof` guard keeps the reference ESM-safe.
|
|
25
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
26
|
+
|
|
27
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded runtime require. */
|
|
28
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
29
|
+
|
|
30
|
+
/** Trim + reject non-strings/empties so we only ever emit a real model string. */
|
|
31
|
+
const coerceModel = (value: unknown): string | undefined => {
|
|
32
|
+
if (typeof value !== "string") return undefined;
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Guarded runtime require. `moduleName` is a VARIABLE (a parameter), so bundlers cannot statically
|
|
39
|
+
* resolve it — a host without the module never fails to build; the call just throws and is caught.
|
|
40
|
+
*/
|
|
41
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
42
|
+
try {
|
|
43
|
+
if (typeof require !== "function") return undefined;
|
|
44
|
+
return require(moduleName);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Read a module's `default` (Expo modules are often consumed as default exports) or the namespace. */
|
|
51
|
+
const interop = (mod: unknown): Record<string, unknown> | undefined => {
|
|
52
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
53
|
+
const def = (mod as { default?: unknown }).default;
|
|
54
|
+
if (def && typeof def === "object") return def as Record<string, unknown>;
|
|
55
|
+
return mod as Record<string, unknown>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Resolve a module namespace, swallowing a throwing require (an uninstalled module throws). */
|
|
59
|
+
const safeInterop = (
|
|
60
|
+
requireModule: OptionalRequire,
|
|
61
|
+
moduleName: string,
|
|
62
|
+
): Record<string, unknown> | undefined => {
|
|
63
|
+
try {
|
|
64
|
+
return interop(requireModule(moduleName));
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Detect the device model via `expo-device`, preferring the human-readable `modelName`
|
|
72
|
+
* ("iPhone 14 Pro") and falling back to the identifier `modelId` ("iPhone15,2"). Returns the first
|
|
73
|
+
* real string, or `undefined` when `expo-device` is absent. Pure and never throws.
|
|
74
|
+
*
|
|
75
|
+
* `requireModule` is injectable so tests can exercise the "found" path without the native module;
|
|
76
|
+
* production defaults to the guarded runtime require above.
|
|
77
|
+
*/
|
|
78
|
+
export const detectNativeModel = (
|
|
79
|
+
requireModule: OptionalRequire = runtimeRequire,
|
|
80
|
+
): string | undefined => {
|
|
81
|
+
try {
|
|
82
|
+
const device = safeInterop(requireModule, "expo-device");
|
|
83
|
+
if (device) {
|
|
84
|
+
const modelName = coerceModel(device.modelName);
|
|
85
|
+
if (modelName) return modelName;
|
|
86
|
+
const modelId = coerceModel(device.modelId);
|
|
87
|
+
if (modelId) return modelId;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Any unexpected read error → undefined; analytics must never crash onboarding.
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -118,6 +118,7 @@ export type { OnboardingAttribution } from "./attribution/attribution";
|
|
|
118
118
|
export { collectDeviceContext } from "./device/deviceContext";
|
|
119
119
|
export type { DeviceContext, DeviceFormFactor } from "./device/deviceContext";
|
|
120
120
|
export { detectAppVersion } from "./device/appVersion";
|
|
121
|
+
export { detectNativeModel } from "./device/deviceModel";
|
|
121
122
|
|
|
122
123
|
// ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
|
|
123
124
|
export { identifyOnboarding, sanitizeUserId, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
|
|
@@ -137,6 +138,7 @@ export type {
|
|
|
137
138
|
ResolvedUserContext,
|
|
138
139
|
ResolveUserContextOptions,
|
|
139
140
|
} from "./context/userContext";
|
|
141
|
+
export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
|
|
140
142
|
|
|
141
143
|
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
142
144
|
export {
|