@wireai/activation 0.10.0 → 0.11.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 +55 -0
- package/README.md +33 -4
- package/dist/analytics/index.d.mts +21 -3
- package/dist/analytics/index.d.ts +21 -3
- package/dist/analytics/index.js +94 -61
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +91 -62
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-DdDkprpM.d.mts → currentSession-C0_odnIW.d.mts} +101 -1
- package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-DdnUq2HQ.d.ts} +101 -1
- package/dist/index.d.mts +3 -49
- package/dist/index.d.ts +3 -49
- package/dist/index.js +50 -41
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +47 -42
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +10 -2
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +10 -2
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +1 -1
- package/dist/reviews/index.d.ts +1 -1
- package/dist/reviews/index.js +24 -9
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +24 -9
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/{transport-Bzb-bcB2.d.mts → transport-BGW9uXZJ.d.mts} +0 -15
- package/dist/{transport-B31G0Cib.d.ts → transport-jUJd5kxu.d.ts} +0 -15
- package/llms.txt +8 -0
- package/package.json +1 -1
- package/src/analytics/analyticsFacade.ts +72 -9
- package/src/analytics/eventQueue.ts +12 -11
- package/src/analytics/index.ts +9 -0
- package/src/analytics/reportClientEvent.ts +12 -2
- package/src/context/userContext.ts +55 -0
- package/src/identity/userIdentity.ts +10 -0
- package/src/index.ts +5 -1
- package/src/questionnaire/transport.ts +5 -1
- package/src/reviews/decision.ts +8 -1
- package/src/reviews/transport.ts +6 -8
- package/src/session-analytics/lifecycle.ts +6 -11
- package/src/session-analytics/useLifecycleEvents.ts +41 -27
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-CNUqMK0D.mjs';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* transport.ts — kit → Wire server requests for the review module, all fire-and-forget
|
|
5
|
-
* (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
|
|
6
|
-
* thin fetch wrapper, Bearer tenant key, swallow every error.
|
|
7
|
-
*
|
|
8
|
-
* • submitReview → POST {serverUrl}/v1/reviews (the review row)
|
|
9
|
-
* • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
|
|
10
|
-
* • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
|
|
11
|
-
*
|
|
12
|
-
* `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
|
|
13
|
-
* events through the SAME transport (stored server-side as event_type='app_event',
|
|
14
|
-
* question_key=<name>), which is what the backend review-firing rules evaluate on — and
|
|
15
|
-
* it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
3
|
/**
|
|
19
4
|
* POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
|
|
20
5
|
* a missing `fetch`, or a network failure is swallowed and the call returns immediately.
|
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Buj9Lw9t.js';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* transport.ts — kit → Wire server requests for the review module, all fire-and-forget
|
|
5
|
-
* (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
|
|
6
|
-
* thin fetch wrapper, Bearer tenant key, swallow every error.
|
|
7
|
-
*
|
|
8
|
-
* • submitReview → POST {serverUrl}/v1/reviews (the review row)
|
|
9
|
-
* • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
|
|
10
|
-
* • reportAppEvent → POST {serverUrl}/v1/events (generic app.* namespace)
|
|
11
|
-
*
|
|
12
|
-
* `reportAppEvent` is the strategic extension: it lets a host report arbitrary in-app
|
|
13
|
-
* events through the SAME transport (stored server-side as event_type='app_event',
|
|
14
|
-
* question_key=<name>), which is what the backend review-firing rules evaluate on — and
|
|
15
|
-
* it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
3
|
/**
|
|
19
4
|
* POST a review (the 1-4 feedback path). Fire-and-forget: a missing target, a build error,
|
|
20
5
|
* a missing `fetch`, or a network failure is swallowed and the call returns immediately.
|
package/llms.txt
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
- `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
|
|
24
24
|
- `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
|
|
25
25
|
- `deriveAnswers(messages)`, `themeFromBrand({ primary })`, `defaultIllustrations`, `DemoOnboarding` (dev/QA, no account).
|
|
26
|
+
|
|
27
|
+
## Subpath surfaces (beyond onboarding)
|
|
28
|
+
|
|
29
|
+
The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
|
|
30
|
+
|
|
31
|
+
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section.
|
|
32
|
+
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
|
|
33
|
+
- `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
|
|
26
34
|
- `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
|
|
27
35
|
|
|
28
36
|
## Files
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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>",
|
|
@@ -30,9 +30,24 @@ import { buildContextEnvelope, type ContextEnvelope } from "./contextEnvelope";
|
|
|
30
30
|
import { getCurrentSessionId } from "./currentSession";
|
|
31
31
|
import { createEventQueue, type EventQueue, type EventQueueOptions } from "./eventQueue";
|
|
32
32
|
import { makeSessionId, type ClientEvent } from "./reportClientEvent";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
analyticsUserIdStorageKey,
|
|
35
|
+
clearPiiFromContext,
|
|
36
|
+
resolveUserContext,
|
|
37
|
+
type WireUserContext,
|
|
38
|
+
} from "../context/userContext";
|
|
34
39
|
import { mintDeviceId, deviceIdStorageKey } from "../context/deviceId";
|
|
35
|
-
import { sanitizeUserId } from "../identity/userIdentity";
|
|
40
|
+
import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
|
|
41
|
+
|
|
42
|
+
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
43
|
+
declare const __DEV__: boolean | undefined;
|
|
44
|
+
|
|
45
|
+
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
|
|
46
|
+
const warnInDev = (message: string): void => {
|
|
47
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
48
|
+
console.warn(message);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
36
51
|
|
|
37
52
|
/** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
|
|
38
53
|
export type AnalyticsProps = Record<string, unknown>;
|
|
@@ -76,6 +91,14 @@ export type CreateAnalyticsConfig = {
|
|
|
76
91
|
* review/questionnaire gating + A/B stickiness. Supply `deviceKey` only to use your OWN id (it wins).
|
|
77
92
|
*/
|
|
78
93
|
userContext?: WireUserContext;
|
|
94
|
+
/**
|
|
95
|
+
* ESCAPE HATCH for the email-shape guard. By default `identify(id)` and a `setUserContext({ userId })`
|
|
96
|
+
* REFUSE to bind an id that looks like an email (`local@domain.tld`) and warn in dev — because a
|
|
97
|
+
* raw email in the opaque `user_id` is a PII leak; an email belongs in the opt-in
|
|
98
|
+
* `userContext.userEmail` field. Set `true` ONLY if your real internal user id genuinely IS an
|
|
99
|
+
* email address and you accept it as the pseudonymous key. Default `false` (guard on).
|
|
100
|
+
*/
|
|
101
|
+
allowEmailAsUserId?: boolean;
|
|
79
102
|
};
|
|
80
103
|
|
|
81
104
|
/** Optional queue tuning knobs, forwarded verbatim to {@link createEventQueue}. */
|
|
@@ -101,6 +124,16 @@ export type Analytics = {
|
|
|
101
124
|
* binds like {@link identify}. Takes effect on subsequent events. Fire-and-forget.
|
|
102
125
|
*/
|
|
103
126
|
setUserContext(partial: Partial<WireUserContext>): void;
|
|
127
|
+
/**
|
|
128
|
+
* LOGOUT: unbind the current user so a shared device never attributes user B's events to user A.
|
|
129
|
+
* Clears the in-memory `boundUserId`, strips the PII / pseudonymous fields (`userId`, `userEmail`,
|
|
130
|
+
* `extra`) from the bound {@link WireUserContext} (keeping the non-PII `device_key` + `appVersion`,
|
|
131
|
+
* which group a DEVICE not a user), and removes the persisted `wireai:analytics:userId:<appId>`
|
|
132
|
+
* key so it cannot be rehydrated on the next launch. Subsequent events are anonymous until the
|
|
133
|
+
* next `identify` / `setUserContext`. Fire-and-forget; mirrors the `reset()` convention on the
|
|
134
|
+
* screen tracker. The standalone `clearUserContext({ storage, appId })` covers the `wire` path.
|
|
135
|
+
*/
|
|
136
|
+
reset(): void;
|
|
104
137
|
/** Attempt an immediate drain of the pending buffer. Fire-and-forget. */
|
|
105
138
|
flush(): void;
|
|
106
139
|
/** Host reconnect signal: reset backoff and drain now. Fire-and-forget. */
|
|
@@ -185,7 +218,7 @@ export const createAnalytics = (
|
|
|
185
218
|
// Per-session, in-memory user binding. Seeded from the init context, then persisted across
|
|
186
219
|
// launches when storage is provided.
|
|
187
220
|
let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
|
|
188
|
-
const storageKey =
|
|
221
|
+
const storageKey = analyticsUserIdStorageKey(config.appId);
|
|
189
222
|
|
|
190
223
|
if (config.storage) {
|
|
191
224
|
void config.storage
|
|
@@ -217,6 +250,19 @@ export const createAnalytics = (
|
|
|
217
250
|
if (boundUserId && !event.user_id) event.user_id = boundUserId;
|
|
218
251
|
};
|
|
219
252
|
|
|
253
|
+
// Email-shape guard for the OPAQUE user id. A raw email bound as `user_id` is a PII leak (it
|
|
254
|
+
// belongs in the opt-in `user_context.user_email`), so refuse it and warn in dev — unless the host
|
|
255
|
+
// opted in via `allowEmailAsUserId`. Returns the id to bind, or `undefined` to refuse.
|
|
256
|
+
const guardUserId = (clean: string): string | undefined => {
|
|
257
|
+
if (config.allowEmailAsUserId || !looksLikeEmail(clean)) return clean;
|
|
258
|
+
warnInDev(
|
|
259
|
+
"[wireai] identify() was called with an email-shaped id. A raw email must NOT be the opaque " +
|
|
260
|
+
"user_id (PII leak) — pass it as userContext.userEmail instead. Binding was skipped. Set " +
|
|
261
|
+
"allowEmailAsUserId:true on createAnalytics if your user id genuinely is an email.",
|
|
262
|
+
);
|
|
263
|
+
return undefined;
|
|
264
|
+
};
|
|
265
|
+
|
|
220
266
|
const setUserContext = (partial: Partial<WireUserContext>): void => {
|
|
221
267
|
if (!partial || typeof partial !== "object") return;
|
|
222
268
|
// Deep-merge `extra` so a partial update adds keys instead of replacing the whole map.
|
|
@@ -226,14 +272,27 @@ export const createAnalytics = (
|
|
|
226
272
|
: undefined;
|
|
227
273
|
userContext = { ...userContext, ...partial };
|
|
228
274
|
if (mergedExtra) userContext.extra = mergedExtra;
|
|
229
|
-
// A user id supplied here binds like `identify` so subsequent events carry `user_id
|
|
275
|
+
// A user id supplied here binds like `identify` so subsequent events carry `user_id` — through
|
|
276
|
+
// the SAME email-shape guard (a raw email must not become the opaque user_id).
|
|
230
277
|
const uid = sanitizeUserId(partial.userId);
|
|
231
278
|
if (uid) {
|
|
232
|
-
|
|
233
|
-
if (
|
|
279
|
+
const bindable = guardUserId(uid);
|
|
280
|
+
if (bindable) {
|
|
281
|
+
boundUserId = bindable;
|
|
282
|
+
if (config.storage) void config.storage.setItem(storageKey, bindable).catch(() => {});
|
|
283
|
+
}
|
|
234
284
|
}
|
|
235
285
|
};
|
|
236
286
|
|
|
287
|
+
const reset = (): void => {
|
|
288
|
+
// In-memory binding cleared: subsequent events carry no user_id until the next identify.
|
|
289
|
+
boundUserId = undefined;
|
|
290
|
+
// Strip PII from the rich context but keep the device-scope fields (device_key / app_version).
|
|
291
|
+
userContext = clearPiiFromContext(userContext);
|
|
292
|
+
// Remove the persisted binding so a relaunch can't rehydrate the previous user's id.
|
|
293
|
+
if (config.storage) void config.storage.removeItem(storageKey).catch(() => {});
|
|
294
|
+
};
|
|
295
|
+
|
|
237
296
|
const track = (event: string, props?: AnalyticsProps): void => {
|
|
238
297
|
if (!event) return;
|
|
239
298
|
const clientEvent: ClientEvent = {
|
|
@@ -264,16 +323,19 @@ export const createAnalytics = (
|
|
|
264
323
|
const clean = sanitizeUserId(userId);
|
|
265
324
|
// Blank / non-string → no binding, no event (sanitizeUserId returns undefined). >128 → truncated.
|
|
266
325
|
if (!clean) return;
|
|
267
|
-
|
|
326
|
+
// Email-shape guard: refuse to bind (and emit) a raw email as the opaque user_id unless opted in.
|
|
327
|
+
const bindable = guardUserId(clean);
|
|
328
|
+
if (!bindable) return;
|
|
329
|
+
boundUserId = bindable;
|
|
268
330
|
if (config.storage) {
|
|
269
|
-
void config.storage.setItem(storageKey,
|
|
331
|
+
void config.storage.setItem(storageKey, bindable).catch(() => {});
|
|
270
332
|
}
|
|
271
333
|
const clientEvent: ClientEvent = {
|
|
272
334
|
event_type: "identify",
|
|
273
335
|
// Reuse the LIVE per-open session id (see `resolveSessionId`) so the server binds identity to
|
|
274
336
|
// the session it already saw instead of back-filling a phantom `session_started`.
|
|
275
337
|
session_id: resolveSessionId(),
|
|
276
|
-
user_id:
|
|
338
|
+
user_id: bindable,
|
|
277
339
|
};
|
|
278
340
|
if (traits && Object.keys(traits).length > 0) clientEvent.meta = JSON.stringify(traits);
|
|
279
341
|
applyContext(clientEvent);
|
|
@@ -285,6 +347,7 @@ export const createAnalytics = (
|
|
|
285
347
|
screen,
|
|
286
348
|
identify,
|
|
287
349
|
setUserContext,
|
|
350
|
+
reset,
|
|
288
351
|
flush: queue.flush,
|
|
289
352
|
notifyOnline: queue.notifyOnline,
|
|
290
353
|
size: queue.size,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* `notifyOnline()`; persistence is the host-injected AsyncStorage-compatible subset.
|
|
25
25
|
*/
|
|
26
26
|
import type { ContextEnvelope } from "./contextEnvelope";
|
|
27
|
-
import type
|
|
27
|
+
import { buildEventsRequest, type ClientEvent, type ClientEventTarget } from "./reportClientEvent";
|
|
28
28
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
29
29
|
|
|
30
30
|
/** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
|
|
@@ -161,6 +161,11 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
161
161
|
const stamp = (event: ClientEvent): ClientEvent => {
|
|
162
162
|
const env = resolveEnvelope();
|
|
163
163
|
const stamped: ClientEvent = { ...event };
|
|
164
|
+
// Client enqueue timestamp: distinguishes a GENUINE repeat (same event fired seconds apart)
|
|
165
|
+
// from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
|
|
166
|
+
// two identical events enqueued in the same millisecond still collapse (a re-render), while the
|
|
167
|
+
// same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
|
|
168
|
+
if (stamped.ts === undefined) stamped.ts = Date.now();
|
|
164
169
|
if (!env) return stamped;
|
|
165
170
|
if (!stamped.device && env.device) stamped.device = env.device;
|
|
166
171
|
if (!stamped.session_id && env.sessionId) stamped.session_id = env.sessionId;
|
|
@@ -227,19 +232,15 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
227
232
|
// The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
|
|
228
233
|
// fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
|
|
229
234
|
const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
|
|
230
|
-
|
|
235
|
+
// Build the /v1/events request through the ONE canonical builder (url + headers + body) so this
|
|
236
|
+
// queue never re-describes the endpoint. The abort-timeout stays: the queue owns retry/dequeue,
|
|
237
|
+
// so a hung request must be cut loose to schedule a backoff rather than block the drain forever.
|
|
238
|
+
const req = buildEventsRequest(target, events);
|
|
239
|
+
if (!req) return false;
|
|
231
240
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
|
|
232
241
|
const timer = setTimeout(() => controller?.abort(), 15_000);
|
|
233
242
|
try {
|
|
234
|
-
const
|
|
235
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
236
|
-
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
237
|
-
const res = await fetch(url, {
|
|
238
|
-
method: "POST",
|
|
239
|
-
headers,
|
|
240
|
-
body: JSON.stringify({ events }),
|
|
241
|
-
signal: controller?.signal,
|
|
242
|
-
});
|
|
243
|
+
const res = await fetch(req.url, { ...req.init, signal: controller?.signal });
|
|
243
244
|
return !!(res && (res as { ok?: boolean }).ok);
|
|
244
245
|
} catch {
|
|
245
246
|
return false;
|
package/src/analytics/index.ts
CHANGED
|
@@ -64,5 +64,14 @@ export type {
|
|
|
64
64
|
// ─── The thin optional React hook over the façade ─────────────────────────────
|
|
65
65
|
export { useAnalytics } from "./useAnalytics";
|
|
66
66
|
|
|
67
|
+
// ─── Logout / reset helpers + the email-shape guard (analytics-focused surface) ─
|
|
68
|
+
export {
|
|
69
|
+
clearUserContext,
|
|
70
|
+
clearPiiFromContext,
|
|
71
|
+
analyticsUserIdStorageKey,
|
|
72
|
+
} from "../context/userContext";
|
|
73
|
+
export type { ClearUserContextOptions } from "../context/userContext";
|
|
74
|
+
export { looksLikeEmail } from "../identity/userIdentity";
|
|
75
|
+
|
|
67
76
|
// ─── Current per-open session registry (identify/app-events reuse the live session) ───
|
|
68
77
|
export { getCurrentSessionId, setCurrentSessionId, resetCurrentSessionId } from "./currentSession";
|
|
@@ -76,6 +76,16 @@ export type ClientEvent = {
|
|
|
76
76
|
* Lets the backend reconcile onboarding sessions to real users. Old servers ignore it.
|
|
77
77
|
*/
|
|
78
78
|
user_id?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Client-stamped epoch-ms timestamp of when the event was ENQUEUED on the device. Optional and
|
|
81
|
+
* ADDITIVE: the offline queue stamps it at enqueue time (see `createEventQueue`) so two otherwise
|
|
82
|
+
* byte-identical events fired seconds apart (a genuine repeat, e.g. the user taps "share" twice)
|
|
83
|
+
* are NOT collapsed by the queue's identical-JSON de-dup — while two truly simultaneous
|
|
84
|
+
* re-enqueues of the same instant (a redundant re-render) still share a `ts` and collapse. A raw
|
|
85
|
+
* `Date.now()`, never a wall-clock the server trusts (the server derives its own receive time);
|
|
86
|
+
* an old/strict server that does not model it simply ignores the unknown field.
|
|
87
|
+
*/
|
|
88
|
+
ts?: number;
|
|
79
89
|
};
|
|
80
90
|
|
|
81
91
|
/** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
|
|
@@ -102,8 +112,8 @@ export const makeSessionId = (): string =>
|
|
|
102
112
|
* copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
|
|
103
113
|
* nothing to send (no target / no events) or serialization throws, so callers just bail.
|
|
104
114
|
*/
|
|
105
|
-
const buildEventsRequest = (
|
|
106
|
-
target:
|
|
115
|
+
export const buildEventsRequest = (
|
|
116
|
+
target: { serverUrl: string; apiKey?: string } | undefined,
|
|
107
117
|
events: ClientEvent[],
|
|
108
118
|
): { url: string; init: RequestInit } | null => {
|
|
109
119
|
if (!target?.serverUrl || events.length === 0) return null;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* dependency-free FNV-1a fold (see {@link hashEmailFnv1a}) — no crypto library, no async.
|
|
24
24
|
*/
|
|
25
25
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
26
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The single, extensible user-context object. A host passes it ONCE (at analytics init) and may
|
|
@@ -152,6 +153,60 @@ export const namespaceExtra = (
|
|
|
152
153
|
return out;
|
|
153
154
|
};
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The storage key the analytics façade persists the bound opaque `user_id` under (namespaced per
|
|
158
|
+
* `appId`, mirroring {@link deviceIdStorageKey}). Exported so a logout path can target it directly.
|
|
159
|
+
*/
|
|
160
|
+
export const analyticsUserIdStorageKey = (appId?: string): string =>
|
|
161
|
+
`wireai:analytics:userId:${appId ?? "default"}`;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Return a COPY of a {@link WireUserContext} with every USER-scoped (PII / pseudonymous) field
|
|
165
|
+
* removed — `userId`, `userEmail`, `hashEmail`, and `extra` — while KEEPING the non-PII device-scope
|
|
166
|
+
* fields (`appVersion`, `deviceKey`). This is the in-memory half of logout: after it, the same
|
|
167
|
+
* analytics instance keeps its stable `device_key` (which groups a DEVICE, not a user) but no longer
|
|
168
|
+
* stamps the previous user's id/email onto events. Pure; never mutates the input.
|
|
169
|
+
*/
|
|
170
|
+
export const clearPiiFromContext = (ctx: WireUserContext = {}): WireUserContext => {
|
|
171
|
+
const rest: WireUserContext = {};
|
|
172
|
+
if (typeof ctx.appVersion === "string") rest.appVersion = ctx.appVersion;
|
|
173
|
+
if (typeof ctx.deviceKey === "string") rest.deviceKey = ctx.deviceKey;
|
|
174
|
+
return rest;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Options for {@link clearUserContext}. */
|
|
178
|
+
export interface ClearUserContextOptions {
|
|
179
|
+
/** Host persistence (AsyncStorage subset) — the persisted bound `user_id` is removed from here. */
|
|
180
|
+
storage?: WireOnboardingStorage;
|
|
181
|
+
/** Tenant/app id — namespaces the persisted key (`wireai:analytics:userId:<appId>`). */
|
|
182
|
+
appId?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* LOGOUT primitive: purge the persisted, bound opaque `user_id` for an app so the NEXT user on a
|
|
187
|
+
* shared device is not silently attributed to the previous one. Removes the
|
|
188
|
+
* `wireai:analytics:userId:<appId>` key that the analytics façade persists and reuses across
|
|
189
|
+
* launches. Fire-and-forget: a missing storage or a failing adapter resolves quietly.
|
|
190
|
+
*
|
|
191
|
+
* COVERAGE. The stateful `createAnalytics(...)` instance also exposes {@link Analytics.reset}, which
|
|
192
|
+
* does this AND clears the in-memory binding + PII in one call — prefer it when you hold the
|
|
193
|
+
* instance. This standalone helper covers the `createWireActivation` / `wire` path (whose config is
|
|
194
|
+
* captured immutably, so it has no `reset`): call `clearUserContext({ storage, appId })` on logout,
|
|
195
|
+
* and RECREATE the `wire` / analytics instance without the user's `userContext` (userId/userEmail)
|
|
196
|
+
* so no further events carry the previous user's identity. The non-PII per-install `device_key`
|
|
197
|
+
* (`wireai:analytics:deviceKey:<appId>`) is intentionally left in place — it groups a device, not a
|
|
198
|
+
* person, and stays stable across users of the same install.
|
|
199
|
+
*/
|
|
200
|
+
export const clearUserContext = async (opts: ClearUserContextOptions = {}): Promise<void> => {
|
|
201
|
+
const storage = opts.storage;
|
|
202
|
+
if (!storage) return;
|
|
203
|
+
try {
|
|
204
|
+
await storage.removeItem(analyticsUserIdStorageKey(opts.appId));
|
|
205
|
+
} catch {
|
|
206
|
+
/* best-effort, swallow — logout must never throw into the UI */
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
155
210
|
/** Options for {@link resolveUserContext}. */
|
|
156
211
|
export interface ResolveUserContextOptions {
|
|
157
212
|
/**
|
|
@@ -44,6 +44,16 @@ export const sanitizeUserId = (raw: unknown): string | undefined => {
|
|
|
44
44
|
return trimmed.length > USER_ID_MAX_LENGTH ? trimmed.slice(0, USER_ID_MAX_LENGTH) : trimmed;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A permissive email-SHAPE test (`local@domain.tld`) — NOT an RFC validator. Its ONE job is to
|
|
49
|
+
* catch the common integration mistake of binding a RAW EMAIL as the opaque `user_id`: that leaks
|
|
50
|
+
* PII into the top-level id (which the server treats as an opaque key and may surface), when the
|
|
51
|
+
* email belongs in the opt-in `user_context.user_email` field instead. `identify()` uses this to
|
|
52
|
+
* refuse an email-shaped id (with a dev warning) unless the host opts in explicitly. Trims first.
|
|
53
|
+
*/
|
|
54
|
+
export const looksLikeEmail = (value: unknown): boolean =>
|
|
55
|
+
typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
|
56
|
+
|
|
47
57
|
/** Options for {@link identifyOnboarding}. */
|
|
48
58
|
export type IdentifyOnboardingOptions = {
|
|
49
59
|
/** Tenant transport, same shape as `WireOnboardingConfig` (only these two fields are used). */
|
package/src/index.ts
CHANGED
|
@@ -141,7 +141,7 @@ export { detectAppVersion } from "./device/appVersion";
|
|
|
141
141
|
export { detectNativeModel } from "./device/deviceModel";
|
|
142
142
|
|
|
143
143
|
// ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
|
|
144
|
-
export { identifyOnboarding, sanitizeUserId, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
|
|
144
|
+
export { identifyOnboarding, sanitizeUserId, looksLikeEmail, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
|
|
145
145
|
export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
|
|
146
146
|
|
|
147
147
|
// ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
|
|
@@ -150,6 +150,9 @@ export {
|
|
|
150
150
|
namespaceExtra,
|
|
151
151
|
hashEmailFnv1a,
|
|
152
152
|
isWireScalar,
|
|
153
|
+
clearUserContext,
|
|
154
|
+
clearPiiFromContext,
|
|
155
|
+
analyticsUserIdStorageKey,
|
|
153
156
|
RESERVED_USER_CONTEXT_KEYS,
|
|
154
157
|
EXTRA_KEY_PREFIX,
|
|
155
158
|
} from "./context/userContext";
|
|
@@ -157,6 +160,7 @@ export type {
|
|
|
157
160
|
WireUserContext,
|
|
158
161
|
ResolvedUserContext,
|
|
159
162
|
ResolveUserContextOptions,
|
|
163
|
+
ClearUserContextOptions,
|
|
160
164
|
} from "./context/userContext";
|
|
161
165
|
export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
|
|
162
166
|
|
|
@@ -75,7 +75,11 @@ export const fetchQuestionnaireDecision = async (
|
|
|
75
75
|
const res = await fetch(url, { headers });
|
|
76
76
|
if (!res || !res.ok) return null;
|
|
77
77
|
const json = (await res.json()) as QuestionnaireDecisionResponse | null;
|
|
78
|
-
|
|
78
|
+
// A body without a boolean `fire` is not a decision. Guard it explicitly (mirrors
|
|
79
|
+
// `fetchReviewDecision`) rather than letting `{}` through as a truthy object a gate would treat
|
|
80
|
+
// as a verdict (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
|
|
81
|
+
if (!json || typeof json.fire !== "boolean") return null;
|
|
82
|
+
return json;
|
|
79
83
|
} catch {
|
|
80
84
|
/* unreachable / non-2xx / bad JSON / missing-fetch - never show */
|
|
81
85
|
return null;
|
package/src/reviews/decision.ts
CHANGED
|
@@ -64,7 +64,14 @@ export const resolveRules = (config: {
|
|
|
64
64
|
oncePerVersion?: boolean;
|
|
65
65
|
}): GateRules => ({
|
|
66
66
|
enabled: config.enabled ?? true,
|
|
67
|
-
|
|
67
|
+
// FAIL-CLOSED on the first session (behavior change, 0.11.0). An unconfigured host with no
|
|
68
|
+
// server decision must NOT prompt on the very first mount: `minSessions` defaults to 2 so the
|
|
69
|
+
// gate needs at least a second session before the LOCAL rules can fire. This is the client-side
|
|
70
|
+
// floor only — a server `decision` (from `fetchReviewDecision`) still OVERRIDES everything, and a
|
|
71
|
+
// host that genuinely wants first-session prompting can set `minSessions: 1` (or `0`) explicitly.
|
|
72
|
+
// Mirrors the 2026-07-16 incident: a first-session user, no server decision, got the prompt and
|
|
73
|
+
// left 1 star. See `evaluateGate` (`min_sessions` reason) and the CHANGELOG.
|
|
74
|
+
minSessions: config.minSessions ?? 2,
|
|
68
75
|
minEvents: config.minEvents ?? 0,
|
|
69
76
|
cooldownDays: config.cooldownDays ?? 0,
|
|
70
77
|
oncePerVersion: config.oncePerVersion ?? true,
|
package/src/reviews/transport.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* question_key=<name>), which is what the backend review-firing rules evaluate on — and
|
|
13
13
|
* it seeds the broader app-analytics stream. Keep payloads minimal + non-PII.
|
|
14
14
|
*/
|
|
15
|
+
import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
|
|
15
16
|
import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -178,9 +179,6 @@ export const reportAppEvent = (
|
|
|
178
179
|
): void => {
|
|
179
180
|
if (!target?.serverUrl || !name) return;
|
|
180
181
|
try {
|
|
181
|
-
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
182
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
183
|
-
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
184
182
|
const event: Record<string, unknown> = {
|
|
185
183
|
event_type: "app_event",
|
|
186
184
|
question_key: name,
|
|
@@ -192,11 +190,11 @@ export const reportAppEvent = (
|
|
|
192
190
|
if (options.meta && Object.keys(options.meta).length > 0) {
|
|
193
191
|
event.meta = JSON.stringify(options.meta);
|
|
194
192
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
193
|
+
// Route through the ONE canonical /v1/events builder (url + headers + body) instead of
|
|
194
|
+
// re-describing the endpoint here; still fire-and-forget.
|
|
195
|
+
const req = buildEventsRequest(target, [event as unknown as ClientEvent]);
|
|
196
|
+
if (!req) return;
|
|
197
|
+
void fetch(req.url, req.init).catch(() => {
|
|
200
198
|
/* best-effort, swallow */
|
|
201
199
|
});
|
|
202
200
|
} catch {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* FIRE-AND-FORGET: like every analytics path in the kit, nothing here throws into the UI, awaits
|
|
35
35
|
* in the caller, or hangs the app — storage reads race a short timeout, writes swallow errors.
|
|
36
36
|
*/
|
|
37
|
-
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
37
|
+
import { buildEventsRequest, makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
38
38
|
import type { DeviceContext } from "../device/deviceContext";
|
|
39
39
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
40
40
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
@@ -141,16 +141,11 @@ const routeLifecycleEvent = (event: Record<string, unknown>, opts: LifecycleEven
|
|
|
141
141
|
opts.sink(event as ClientEvent);
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
void fetch(url, {
|
|
150
|
-
method: "POST",
|
|
151
|
-
headers,
|
|
152
|
-
body: JSON.stringify({ events: [event] }),
|
|
153
|
-
}).catch(() => {
|
|
144
|
+
// Route the direct-POST fallback through the ONE canonical /v1/events builder (url + headers +
|
|
145
|
+
// body) instead of re-describing the endpoint here; still fire-and-forget.
|
|
146
|
+
const req = buildEventsRequest(opts.target, [event as unknown as ClientEvent]);
|
|
147
|
+
if (!req) return;
|
|
148
|
+
void fetch(req.url, req.init).catch(() => {
|
|
154
149
|
// Network/transport error — analytics is best-effort, swallow.
|
|
155
150
|
});
|
|
156
151
|
} catch {
|
|
@@ -28,7 +28,7 @@ import { useEffect, useRef } from "react";
|
|
|
28
28
|
import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
29
29
|
|
|
30
30
|
import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
|
|
31
|
-
import type
|
|
31
|
+
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
32
32
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
33
33
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
34
34
|
import { reportFirstOpen } from "./lifecycle";
|
|
@@ -114,7 +114,45 @@ export const useLifecycleEvents = (
|
|
|
114
114
|
const targetOf = (cfg: LifecycleConfig | undefined): ClientEventTarget | undefined =>
|
|
115
115
|
cfg?.serverUrl ? { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" } : undefined;
|
|
116
116
|
|
|
117
|
-
//
|
|
117
|
+
// ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
|
|
118
|
+
// WHY: `first_open` used to default to its OWN fresh `makeSessionId()` (via `buildLifecycleEvent`)
|
|
119
|
+
// while `session_started` minted a different one — so every install's `first_open` carried a
|
|
120
|
+
// session id the server never got a `session_started` for (a PHANTOM session the server
|
|
121
|
+
// back-filled). Minting one id here and handing it to both makes them agree, so the server sees
|
|
122
|
+
// `first_open` under the same session it saw `session_started` for. A later foreground open
|
|
123
|
+
// (below) mints its OWN fresh id — a new open is a new session.
|
|
124
|
+
const mountOpenSessionId = makeSessionId();
|
|
125
|
+
|
|
126
|
+
// session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
|
|
127
|
+
// Accepts the per-open id so the mount open reuses `mountOpenSessionId`; omit it and the emitter
|
|
128
|
+
// mints a fresh id (a genuinely new open). Either way the once-guard dedupes within the open.
|
|
129
|
+
const fireSession = (sessionId?: string) => {
|
|
130
|
+
const { config: cfg, options: opts } = latest.current;
|
|
131
|
+
if (opts.enabled === false) return;
|
|
132
|
+
if (!cfg?.serverUrl && !opts.sink) return;
|
|
133
|
+
const device = collectDeviceContext();
|
|
134
|
+
// `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
|
|
135
|
+
if (cfg?.appVersion) device.appVersion = cfg.appVersion;
|
|
136
|
+
reportSessionStart({
|
|
137
|
+
target: targetOf(cfg),
|
|
138
|
+
sink: resolveSink(),
|
|
139
|
+
sessionId,
|
|
140
|
+
userId: opts.userId,
|
|
141
|
+
deviceKey: opts.deviceKey,
|
|
142
|
+
sessionCount: opts.sessionCount,
|
|
143
|
+
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
144
|
+
platform: Platform.OS,
|
|
145
|
+
device,
|
|
146
|
+
meta: opts.meta,
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// 1) session_start FIRST (mount = an app-open), so the server has ingested `session_started`
|
|
151
|
+
// for `mountOpenSessionId` before first_open references the same id.
|
|
152
|
+
fireSession(mountOpenSessionId);
|
|
153
|
+
|
|
154
|
+
// 2) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to
|
|
155
|
+
// the SAME per-open id so it is never a phantom session.
|
|
118
156
|
{
|
|
119
157
|
const { config: cfg, options: opts } = latest.current;
|
|
120
158
|
if (opts.enabled !== false) {
|
|
@@ -124,6 +162,7 @@ export const useLifecycleEvents = (
|
|
|
124
162
|
reportFirstOpen({
|
|
125
163
|
target: targetOf(cfg),
|
|
126
164
|
sink: resolveSink(),
|
|
165
|
+
sessionId: mountOpenSessionId,
|
|
127
166
|
storage: cfg?.storage,
|
|
128
167
|
appId: cfg?.appId,
|
|
129
168
|
userId: opts.userId,
|
|
@@ -137,31 +176,6 @@ export const useLifecycleEvents = (
|
|
|
137
176
|
}
|
|
138
177
|
}
|
|
139
178
|
|
|
140
|
-
// 2) session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
|
|
141
|
-
const fireSession = () => {
|
|
142
|
-
const { config: cfg, options: opts } = latest.current;
|
|
143
|
-
if (opts.enabled === false) return;
|
|
144
|
-
if (!cfg?.serverUrl && !opts.sink) return;
|
|
145
|
-
const device = collectDeviceContext();
|
|
146
|
-
// `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
|
|
147
|
-
if (cfg?.appVersion) device.appVersion = cfg.appVersion;
|
|
148
|
-
reportSessionStart({
|
|
149
|
-
target: targetOf(cfg),
|
|
150
|
-
sink: resolveSink(),
|
|
151
|
-
// A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
|
|
152
|
-
userId: opts.userId,
|
|
153
|
-
deviceKey: opts.deviceKey,
|
|
154
|
-
sessionCount: opts.sessionCount,
|
|
155
|
-
appVersion: cfg?.appVersion ?? device.appVersion,
|
|
156
|
-
platform: Platform.OS,
|
|
157
|
-
device,
|
|
158
|
-
meta: opts.meta,
|
|
159
|
-
});
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
// Mount = an app-open.
|
|
163
|
-
fireSession();
|
|
164
|
-
|
|
165
179
|
// Foreground after a real background = a new app-open.
|
|
166
180
|
let backgroundedAt: number | null = null;
|
|
167
181
|
const onChange = (state: AppStateStatus) => {
|