@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
|
@@ -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,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
|
-
/**
|
|
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).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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").
|
|
28
|
-
*
|
|
29
|
-
* (`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* Host app version string (e.g. "1.4.2"). OPTIONAL — when 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
|
};
|