@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.
@@ -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 has no imports beyond React Native built-ins):
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. There are NO advertising IDs, NO
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,9 @@
17
20
  */
18
21
  import { Dimensions, I18nManager, Platform } from "react-native";
19
22
 
23
+ import { detectAppVersion } from "./appVersion";
24
+ import { detectNativeModel } from "./deviceModel";
25
+
20
26
  /** Coarse device class. iOS uses the reported interface idiom; else a screen-size heuristic. */
21
27
  export type DeviceFormFactor = "phone" | "tablet";
22
28
 
@@ -31,7 +37,15 @@ export type DeviceContext = {
31
37
  osVersion?: string;
32
38
  /** Android device brand (e.g. "samsung"). Android only. */
33
39
  brand?: string;
34
- /** Android device model (e.g. "SM-G991B"). Android only. */
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
+ */
35
49
  model?: string;
36
50
  /** iOS interface idiom ("phone" | "pad" | …), when reported. iOS only. */
37
51
  interfaceIdiom?: string;
@@ -50,9 +64,11 @@ export type DeviceContext = {
50
64
  /** IANA time zone (e.g. "Europe/Berlin"), from `Intl` when available. */
51
65
  timeZone?: string;
52
66
  /**
53
- * Host app version (e.g. "1.4.2). HOST-INJECTED NOT collected here. `WireOnboarding`
54
- * merges `config.appVersion` into the snapshot; `collectDeviceContext()` never sets it.
55
- * Hosts typically pass it from `expo-constants` (the kit itself adds no dependency).
67
+ * Host app version (e.g. "1.4.2"). BEST-EFFORT auto-detected here via `detectAppVersion()`
68
+ * (reads `expo-constants` / `expo-application` when present; adds no dependency — see
69
+ * device/appVersion.ts). An explicit host-injected `config.appVersion` always WINS: the merge
70
+ * sites (`WireOnboarding`, the session-analytics hooks, the context envelope) overwrite this
71
+ * with the host value when one is supplied. Omitted when neither source yields a version.
56
72
  */
57
73
  appVersion?: string;
58
74
  };
@@ -118,6 +134,10 @@ export const collectDeviceContext = (): DeviceContext => {
118
134
  ctx.interfaceIdiom = idiom;
119
135
  iosIdiom = idiom;
120
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;
121
141
  }
122
142
  } catch {
123
143
  // ignore per-OS constant reads
@@ -154,5 +174,10 @@ export const collectDeviceContext = (): DeviceContext => {
154
174
  // Intl unavailable — omit locale/timeZone.
155
175
  }
156
176
 
177
+ // Best-effort host app version (adds no dependency; omitted when unavailable). An explicit
178
+ // `config.appVersion` overrides this downstream at the merge sites.
179
+ const appVersion = detectAppVersion();
180
+ if (appVersion) ctx.appVersion = appVersion;
181
+
157
182
  return ctx;
158
183
  };
@@ -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
+ };
@@ -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(
package/src/index.ts CHANGED
@@ -117,11 +117,36 @@ export type { OnboardingAttribution } from "./attribution/attribution";
117
117
  // ─── Device context (privacy-label-neutral, dependency-free) ──────────────────
118
118
  export { collectDeviceContext } from "./device/deviceContext";
119
119
  export type { DeviceContext, DeviceFormFactor } from "./device/deviceContext";
120
+ export { detectAppVersion } from "./device/appVersion";
121
+ export { detectNativeModel } from "./device/deviceModel";
120
122
 
121
123
  // ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
122
124
  export { identifyOnboarding, sanitizeUserId, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
123
125
  export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
124
126
 
127
+ // ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
128
+ export {
129
+ resolveUserContext,
130
+ namespaceExtra,
131
+ hashEmailFnv1a,
132
+ isWireScalar,
133
+ RESERVED_USER_CONTEXT_KEYS,
134
+ EXTRA_KEY_PREFIX,
135
+ } from "./context/userContext";
136
+ export type {
137
+ WireUserContext,
138
+ ResolvedUserContext,
139
+ ResolveUserContextOptions,
140
+ } from "./context/userContext";
141
+ export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
142
+
143
+ // ─── Current per-open session registry (identify/app-events reuse the live session) ───
144
+ export {
145
+ getCurrentSessionId,
146
+ setCurrentSessionId,
147
+ resetCurrentSessionId,
148
+ } from "./analytics/currentSession";
149
+
125
150
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
126
151
  export {
127
152
  reportSessionStart,
@@ -31,6 +31,7 @@
31
31
  * awaits, and swallows a missing target / bad URL / missing fetch / network error. Analytics must
32
32
  * never be able to break the app.
33
33
  */
34
+ import { setCurrentSessionId } from "../analytics/currentSession";
34
35
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
36
  import type { DeviceContext } from "../device/deviceContext";
36
37
  import { sanitizeUserId } from "../identity/userIdentity";
@@ -104,6 +105,12 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
104
105
 
105
106
  const sessionId = opts.sessionId ?? makeSessionId();
106
107
 
108
+ // Register this open as the CURRENT per-open session so the analytics façade's `identify` /
109
+ // app-events reuse the id the server sees here (via `app.session_started`) instead of minting a
110
+ // fresh one it would back-fill into a phantom session. Idempotent; set before the once-guard so
111
+ // even a guard-deduped repeat keeps the current id pointing at this open.
112
+ setCurrentSessionId(sessionId);
113
+
107
114
  // Once-per-open guard: skip a repeat emit for a session id we already sent.
108
115
  if (opts.once !== false) {
109
116
  if (_emitted.has(sessionId)) return;
@@ -119,7 +119,8 @@ export const useLifecycleEvents = (
119
119
  const { config: cfg, options: opts } = latest.current;
120
120
  if (opts.enabled !== false) {
121
121
  const device = collectDeviceContext();
122
- if (cfg?.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
122
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
123
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
123
124
  reportFirstOpen({
124
125
  target: targetOf(cfg),
125
126
  sink: resolveSink(),
@@ -142,7 +143,8 @@ export const useLifecycleEvents = (
142
143
  if (opts.enabled === false) return;
143
144
  if (!cfg?.serverUrl && !opts.sink) return;
144
145
  const device = collectDeviceContext();
145
- if (cfg?.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
146
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
147
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
146
148
  reportSessionStart({
147
149
  target: targetOf(cfg),
148
150
  sink: resolveSink(),
@@ -72,7 +72,8 @@ export const useSessionStart = (
72
72
  if (opts.enabled === false) return;
73
73
  const target: ClientEventTarget = { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" };
74
74
  const device = collectDeviceContext();
75
- if (cfg.appVersion && !device.appVersion) device.appVersion = cfg.appVersion;
75
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
76
+ if (cfg.appVersion) device.appVersion = cfg.appVersion;
76
77
  reportSessionStart({
77
78
  target,
78
79
  // A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
package/src/types.ts CHANGED
@@ -24,11 +24,12 @@ export type WireOnboardingConfig = {
24
24
  */
25
25
  metadata?: Record<string, unknown>;
26
26
  /**
27
- * Host app version string (e.g. "1.4.2"). HOST-INJECTED — the kit adds no dependency to
28
- * read it; hosts typically pass it from `expo-constants`
29
- * (`Constants.expoConfig?.version`). Forwarded to the backend on the session metadata and
30
- * on client events (merged into the `device` snapshot as `device.appVersion`) so analytics
31
- * can segment the funnel by app version. Optional; omit if unknown.
27
+ * Host app version string (e.g. "1.4.2"). OPTIONALwhen omitted, the kit makes a best-effort
28
+ * auto-detection from `expo-constants` (`Constants.expoConfig?.version` / `nativeAppVersion`) or
29
+ * `expo-application` (`nativeApplicationVersion`) WITHOUT adding a dependency (a host that lacks
30
+ * those modules just gets no version see device/appVersion.ts). Pass this to override the
31
+ * auto-detected value (it always wins). Forwarded to the backend on the session metadata and on
32
+ * client events (as `device.appVersion`) so analytics can segment the funnel by app version.
32
33
  */
33
34
  appVersion?: string;
34
35
  };