@wireai/activation 0.1.0 → 0.2.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 +82 -40
- package/CHANGELOG.md +17 -2
- package/INTEGRATION_PROMPT.md +8 -8
- package/README.md +38 -38
- package/dist/analytics/index.d.mts +92 -0
- package/dist/analytics/index.d.ts +92 -0
- package/dist/analytics/index.js +439 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/index.mjs +426 -0
- package/dist/analytics/index.mjs.map +1 -0
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/decision-BzbiKwk3.d.mts +79 -0
- package/dist/decision-plDEOCkt.d.ts +79 -0
- package/dist/eventQueue-CxKi7Qd5.d.mts +546 -0
- package/dist/eventQueue-rV1dtJJR.d.ts +546 -0
- package/dist/index.d.mts +162 -418
- package/dist/index.d.ts +162 -418
- package/dist/index.js +407 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +402 -5
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +2 -1
- package/dist/questionnaire/index.d.ts +2 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +5 -40
- package/dist/reviews/index.d.ts +5 -40
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/transport-BeO_Brcu.d.mts +40 -0
- package/dist/transport-DLpd1v5_.d.ts +40 -0
- package/dist/{decision-Cl8OFYzu.d.mts → types-A6pTxIZV.d.mts} +1 -77
- package/dist/{decision-CFvGY6nP.d.ts → types-BhpXJGlg.d.ts} +1 -77
- package/llms.txt +7 -7
- package/metro/index.d.ts +3 -3
- package/metro/index.js +3 -3
- package/package.json +15 -1
- package/src/analytics/contextEnvelope.ts +72 -0
- package/src/analytics/eventQueue.ts +331 -0
- package/src/analytics/index.ts +47 -0
- package/src/analytics/screenTracking.ts +122 -0
- package/src/analytics/useScreenTracking.ts +48 -0
- package/src/coachmarks/index.ts +2 -2
- package/src/features/WireFeaturesProvider.tsx +1 -1
- package/src/features/index.ts +2 -2
- package/src/index.ts +18 -1
- package/src/questionnaire/index.ts +2 -2
- package/src/reviews/index.ts +2 -2
- package/src/session-analytics/index.ts +20 -0
- package/src/session-analytics/lifecycle.ts +236 -0
- package/src/session-analytics/reportSessionStart.ts +27 -7
- package/src/session-analytics/useLifecycleEvents.ts +184 -0
- package/src/showcase/index.ts +2 -2
- package/src/types.ts +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* screenTracking — an OPT-IN, dependency-free automatic screen-view helper.
|
|
3
|
+
*
|
|
4
|
+
* Given the host app's navigation STATE (or a nav ref, via the useScreenTracking hook), this
|
|
5
|
+
* emits exactly ONE `screen` app-event per REAL screen change through the kit's existing
|
|
6
|
+
* `reportAppEvent` transport (`POST {serverUrl}/v1/events`, `event_type='app_event'`,
|
|
7
|
+
* `question_key='screen'`, `meta={ screen }`). Param-only changes and re-renders are de-duped
|
|
8
|
+
* away because we resolve and compare the route NAME only — never the params.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-free core: this file imports NO navigation library. React-Navigation / expo-router
|
|
11
|
+
* are host concerns; the host supplies plain state objects and refs, typed structurally here
|
|
12
|
+
* (`NavigationStateLike`). It is also React-free — the optional React glue lives in
|
|
13
|
+
* `useScreenTracking.ts`. `reportAppEvent` is imported from the pure `../reviews/transport`
|
|
14
|
+
* module (NOT the `../reviews` barrel, which would drag the review UI into an analytics-only
|
|
15
|
+
* bundle and defeat tree-shaking).
|
|
16
|
+
*
|
|
17
|
+
* Privacy: only the route NAME ever leaves the device. Route params, query strings, and any
|
|
18
|
+
* user data are never read into the event. Fire-and-forget — this never throws into the UI.
|
|
19
|
+
*/
|
|
20
|
+
import { reportAppEvent } from "../reviews/transport";
|
|
21
|
+
|
|
22
|
+
/** A single route inside a React-Navigation-shaped state (structural — no `@react-navigation`). */
|
|
23
|
+
export interface NavigationRouteLike {
|
|
24
|
+
name: string;
|
|
25
|
+
/** A nested navigator's own state, when this route hosts one. */
|
|
26
|
+
state?: NavigationStateLike;
|
|
27
|
+
/** Route params are intentionally left `unknown` — this helper never reads them. */
|
|
28
|
+
params?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A React-Navigation-shaped navigator state (structural type; no library import). */
|
|
32
|
+
export interface NavigationStateLike {
|
|
33
|
+
/** Index of the active route within `routes`. */
|
|
34
|
+
index?: number;
|
|
35
|
+
routes?: NavigationRouteLike[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options for {@link createScreenTracker}. */
|
|
39
|
+
export interface ScreenTrackerOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Where to POST. `{ serverUrl, apiKey }` — same shape the kit's review/analytics config
|
|
42
|
+
* exposes. When omitted, the tracker still de-dups and fires `onScreen`, but sends nothing.
|
|
43
|
+
*/
|
|
44
|
+
target?: { serverUrl: string; apiKey: string };
|
|
45
|
+
/** The onboarding/session id to correlate screen views with, when known. */
|
|
46
|
+
sessionId?: string;
|
|
47
|
+
/** A stable, non-PII device id — groups a device's sessions server-side. */
|
|
48
|
+
deviceKey?: string;
|
|
49
|
+
/** Called on every REAL screen change (after de-dup), before the network emit. */
|
|
50
|
+
onScreen?: (screen: string) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Per-screen filter. Return `false` to skip the NETWORK emit for a screen (last-screen memory
|
|
53
|
+
* is still advanced + `onScreen` still fires) — e.g. to keep a sensitive route out of analytics.
|
|
54
|
+
*/
|
|
55
|
+
shouldTrack?: (screen: string) => boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The screen tracker returned by {@link createScreenTracker}. */
|
|
59
|
+
export interface ScreenTracker {
|
|
60
|
+
/** Report the active screen. Ignores `undefined`/empty and de-dups repeats of the last screen. */
|
|
61
|
+
track: (screen: string | undefined) => void;
|
|
62
|
+
/** Clear the last-screen memory (e.g. on logout) so the next `track` always emits. */
|
|
63
|
+
reset: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Walk a React-Navigation-shaped state to the DEEPEST active route and return its NAME (never
|
|
68
|
+
* its params). Recurses `routes[index]` while a nested `.state` exists. Returns `undefined` for
|
|
69
|
+
* a missing/empty/malformed state — the caller treats that as "nothing to report".
|
|
70
|
+
*/
|
|
71
|
+
export const getActiveRouteName = (
|
|
72
|
+
state: NavigationStateLike | undefined,
|
|
73
|
+
): string | undefined => {
|
|
74
|
+
let current: NavigationStateLike | undefined = state;
|
|
75
|
+
let name: string | undefined;
|
|
76
|
+
// Bounded by the finite nesting depth of a real navigator tree.
|
|
77
|
+
while (current && Array.isArray(current.routes) && current.routes.length > 0) {
|
|
78
|
+
const index = typeof current.index === "number" ? current.index : 0;
|
|
79
|
+
const route = current.routes[index];
|
|
80
|
+
if (!route) break;
|
|
81
|
+
name = route.name;
|
|
82
|
+
current = route.state;
|
|
83
|
+
}
|
|
84
|
+
return name;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a stateful screen tracker. `track` de-dups against the last reported screen so only a
|
|
89
|
+
* REAL change emits; `reset` clears that memory. Fire-and-forget throughout — a missing `target`
|
|
90
|
+
* skips the network but keeps the de-dup + `onScreen` behaviour intact.
|
|
91
|
+
*/
|
|
92
|
+
export const createScreenTracker = (options: ScreenTrackerOptions = {}): ScreenTracker => {
|
|
93
|
+
let lastScreen: string | undefined;
|
|
94
|
+
return {
|
|
95
|
+
track: (screen: string | undefined): void => {
|
|
96
|
+
if (!screen || screen === lastScreen) return;
|
|
97
|
+
lastScreen = screen;
|
|
98
|
+
options.onScreen?.(screen);
|
|
99
|
+
if (options.shouldTrack && !options.shouldTrack(screen)) return;
|
|
100
|
+
reportAppEvent(options.target, "screen", {
|
|
101
|
+
sessionId: options.sessionId,
|
|
102
|
+
deviceKey: options.deviceKey,
|
|
103
|
+
meta: { screen },
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
reset: (): void => {
|
|
107
|
+
lastScreen = undefined;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Adapt a tracker into a React-Navigation `onStateChange` handler — the one-place wiring:
|
|
114
|
+
*
|
|
115
|
+
* <NavigationContainer onStateChange={screenTrackingHandler(tracker)}>
|
|
116
|
+
*
|
|
117
|
+
* It resolves the deepest active route name and hands it to `tracker.track` (which de-dups).
|
|
118
|
+
*/
|
|
119
|
+
export const screenTrackingHandler =
|
|
120
|
+
(tracker: ScreenTracker) =>
|
|
121
|
+
(state: NavigationStateLike | undefined): void =>
|
|
122
|
+
tracker.track(getActiveRouteName(state));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScreenTracking — a THIN optional React hook over the pure screen-tracking core.
|
|
3
|
+
*
|
|
4
|
+
* It builds one tracker for the component's lifetime and subscribes to the host's navigation
|
|
5
|
+
* ref: it reports the current route on mount and on every `state` event, and unsubscribes on
|
|
6
|
+
* unmount. React is a REQUIRED peer of the kit, so importing it here is allowed; the hook adds
|
|
7
|
+
* NO navigation-library dependency — the ref is typed structurally (`NavigationRefLike`).
|
|
8
|
+
*
|
|
9
|
+
* const navigationRef = useNavigationContainerRef(); // host's @react-navigation ref
|
|
10
|
+
* useScreenTracking(navigationRef, { target, sessionId });
|
|
11
|
+
* // ...<NavigationContainer ref={navigationRef}>
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useRef } from "react";
|
|
14
|
+
|
|
15
|
+
import { createScreenTracker, type ScreenTracker, type ScreenTrackerOptions } from "./screenTracking";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The structural slice of a React-Navigation container ref this hook needs — no
|
|
19
|
+
* `@react-navigation` import. `getCurrentRoute` yields the active route; `addListener("state", …)`
|
|
20
|
+
* fires on every navigation state change and returns its own unsubscribe.
|
|
21
|
+
*/
|
|
22
|
+
export interface NavigationRefLike {
|
|
23
|
+
getCurrentRoute?: () => { name?: string } | undefined;
|
|
24
|
+
addListener?: (type: "state", callback: () => void) => () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subscribe screen tracking to a host navigation ref. Safe to call with a not-yet-ready ref
|
|
29
|
+
* (the effect no-ops until `addListener` exists). Returns nothing — it wires side effects only.
|
|
30
|
+
*/
|
|
31
|
+
export const useScreenTracking = (
|
|
32
|
+
navigationRef: NavigationRefLike | undefined,
|
|
33
|
+
options: ScreenTrackerOptions = {},
|
|
34
|
+
): void => {
|
|
35
|
+
// One tracker per mount; kept in a ref so re-renders never rebuild the de-dup memory.
|
|
36
|
+
const trackerRef = useRef<ScreenTracker | undefined>(undefined);
|
|
37
|
+
if (!trackerRef.current) trackerRef.current = createScreenTracker(options);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const tracker = trackerRef.current;
|
|
41
|
+
if (!tracker || !navigationRef?.addListener) return;
|
|
42
|
+
const report = (): void => tracker.track(navigationRef.getCurrentRoute?.()?.name);
|
|
43
|
+
report(); // initial screen on mount
|
|
44
|
+
const unsubscribe = navigationRef.addListener("state", report);
|
|
45
|
+
return unsubscribe;
|
|
46
|
+
// Re-subscribe only when the ref identity changes; option changes are read live off the closure.
|
|
47
|
+
}, [navigationRef]);
|
|
48
|
+
};
|
package/src/coachmarks/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wireai
|
|
2
|
+
* @wireai/activation/coachmarks — the performance-first guided-tour engine.
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
|
|
5
5
|
* free: importing this pulls in the optional peers `react-native-reanimated` and
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* import {
|
|
11
11
|
* CoachmarkProvider, useCoachmarkAnchor, useCoachmarkTour,
|
|
12
|
-
* } from "wireai
|
|
12
|
+
* } from "@wireai/activation/coachmarks";
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
// ─── Root provider (mount once, around the NavigationContainer) ───────────────
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* NOTE on the built npm subpaths: React context is shared across the kit's subpath entries in the
|
|
11
11
|
* SOURCE-consumption path (the monorepo apps) and in this test suite — one module instance, one
|
|
12
|
-
* context. If you consume the built
|
|
12
|
+
* context. If you consume the built `@wireai/activation/*` subpaths as separate bundles, pass the
|
|
13
13
|
* resolved flags (or a `featuresConfig`) to each surface explicitly instead of relying on context.
|
|
14
14
|
*/
|
|
15
15
|
import React, { createContext, useContext, useMemo } from "react";
|
package/src/features/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wireai
|
|
2
|
+
* @wireai/activation feature controls — the per-module dashboard kill switches, consumed by the
|
|
3
3
|
* kit with a HARD fail-open contract (a control-plane outage can never dark your app).
|
|
4
4
|
*
|
|
5
5
|
* These live in the CORE barrel (dependency-free: types + fetch + a React hook/provider). The
|
|
6
6
|
* GATING is wired into the three client surfaces (coachmarks / showcase / reviews); onboarding
|
|
7
7
|
* mode is server-owned, so the kit needs no gating there.
|
|
8
8
|
*
|
|
9
|
-
* import { WireFeaturesProvider, fetchWireFeatures } from "wireai
|
|
9
|
+
* import { WireFeaturesProvider, fetchWireFeatures } from "@wireai/activation";
|
|
10
10
|
*/
|
|
11
11
|
export { fetchWireFeatures } from "./fetchWireFeatures";
|
|
12
12
|
export { useWireFeatures } from "./useWireFeatures";
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wireai
|
|
2
|
+
* @wireai/activation — premium, themable drop-in onboarding kit on top of the
|
|
3
3
|
* open-source wireai-rn SDK. Public surface.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -136,6 +136,23 @@ export type {
|
|
|
136
136
|
UseSessionStartOptions,
|
|
137
137
|
} from "./session-analytics";
|
|
138
138
|
|
|
139
|
+
// ─── Lifecycle events (top-of-funnel: `app.first_open` once-ever + session_start) ──
|
|
140
|
+
export {
|
|
141
|
+
reportFirstOpen,
|
|
142
|
+
wireLifecycleEvents,
|
|
143
|
+
resetFirstOpenLatch,
|
|
144
|
+
firstOpenStorageKey,
|
|
145
|
+
FIRST_OPEN_EVENT,
|
|
146
|
+
useLifecycleEvents,
|
|
147
|
+
} from "./session-analytics";
|
|
148
|
+
export type {
|
|
149
|
+
ReportFirstOpenOptions,
|
|
150
|
+
WireLifecycleOptions,
|
|
151
|
+
LifecycleEventInput,
|
|
152
|
+
LifecycleConfig,
|
|
153
|
+
UseLifecycleEventsOptions,
|
|
154
|
+
} from "./session-analytics";
|
|
155
|
+
|
|
139
156
|
// ─── Session persistence (host-injected storage; see the `storage` prop) ──────
|
|
140
157
|
export {
|
|
141
158
|
loadPersistedSession,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wireai
|
|
2
|
+
* @wireai/activation/questionnaire - the skippable pre-onboarding questionnaire popup.
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-free. The gate
|
|
5
5
|
* asks one mandatory question ("what do you think about the app") plus two optional boxes,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* at most once per user per questionnaire id. It reuses the shared `CenteredModal` shell (never
|
|
9
9
|
* importing reviews/ internals for UI).
|
|
10
10
|
*
|
|
11
|
-
* import { QuestionnaireGate, useQuestionnaireGate } from "wireai
|
|
11
|
+
* import { QuestionnaireGate, useQuestionnaireGate } from "@wireai/activation/questionnaire";
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
// ─── The popup UI ───────────────────────────────────────────────────────────────
|
package/src/reviews/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wireai
|
|
2
|
+
* @wireai/activation/reviews — the in-app review sentiment gate.
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-free:
|
|
5
5
|
* importing this pulls in the OPTIONAL peer `expo-store-review` (loaded lazily and only on
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* review and 1-4 to a feather-light feedback form POSTed to the Wire server, and decides
|
|
8
8
|
* WHEN to show via local rules OR a server-provided decision (the AI seam).
|
|
9
9
|
*
|
|
10
|
-
* import { ReviewGate, useReviewGate } from "wireai
|
|
10
|
+
* import { ReviewGate, useReviewGate } from "@wireai/activation/reviews";
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
// ─── The gate UI ──────────────────────────────────────────────────────────────
|
|
@@ -16,3 +16,23 @@ export type { ReportSessionStartOptions } from "./reportSessionStart";
|
|
|
16
16
|
|
|
17
17
|
export { useSessionStart, BACKGROUND_SESSION_MS } from "./useSessionStart";
|
|
18
18
|
export type { SessionStartConfig, UseSessionStartOptions } from "./useSessionStart";
|
|
19
|
+
|
|
20
|
+
// ─── Top-of-funnel lifecycle events (first_open once-ever + session_start reused) ──
|
|
21
|
+
export {
|
|
22
|
+
reportFirstOpen,
|
|
23
|
+
wireLifecycleEvents,
|
|
24
|
+
resetFirstOpenLatch,
|
|
25
|
+
firstOpenStorageKey,
|
|
26
|
+
FIRST_OPEN_EVENT,
|
|
27
|
+
} from "./lifecycle";
|
|
28
|
+
export type {
|
|
29
|
+
ReportFirstOpenOptions,
|
|
30
|
+
WireLifecycleOptions,
|
|
31
|
+
LifecycleEventInput,
|
|
32
|
+
} from "./lifecycle";
|
|
33
|
+
|
|
34
|
+
export { useLifecycleEvents } from "./useLifecycleEvents";
|
|
35
|
+
export type {
|
|
36
|
+
LifecycleConfig,
|
|
37
|
+
UseLifecycleEventsOptions,
|
|
38
|
+
} from "./useLifecycleEvents";
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lifecycle — the TOP-OF-FUNNEL app lifecycle events that sit ABOVE onboarding: `app.first_open`
|
|
3
|
+
* (once ever per install) and `app.session_started` (per app-open). They compose with, and never
|
|
4
|
+
* duplicate, the events the funnel already records.
|
|
5
|
+
*
|
|
6
|
+
* WHERE THIS FITS (one funnel, no double counting):
|
|
7
|
+
* • `app.first_open` → THIS module, once ever (persisted flag). The in-app "install" proxy.
|
|
8
|
+
* • `app.session_started` → the EXISTING {@link reportSessionStart} emitter (per app-open). This
|
|
9
|
+
* module REUSES it (routed through the offline queue), never re-emits.
|
|
10
|
+
* • onboarding started / completed / activated → ALREADY recorded: the server writes
|
|
11
|
+
* `session_started` + `completed` during the A2A flow, and the client reports `dropped` /
|
|
12
|
+
* `client_fallback` / `identify` via `reportClientEvent`. This module does NOT touch them —
|
|
13
|
+
* emitting them here would double-count. It only ADDS the two lifecycle events above them.
|
|
14
|
+
*
|
|
15
|
+
* WIRE CONTRACT (identical to `app.session_started`): both lifecycle events are stored as
|
|
16
|
+
* `event_type='app_event'` with the name in `question_key` (the server's `_event_name` returns
|
|
17
|
+
* `question_key` for an `app_event`, which a trigger matches). `app.first_open` uses the SAME
|
|
18
|
+
* `app.*` namespace + the SAME event shape as `app.session_started` — no bespoke `event_type`,
|
|
19
|
+
* no invented contract. `device_key` rides in the non-PII `user_context` bucket; `app_id` +
|
|
20
|
+
* `environment` are filled server-side.
|
|
21
|
+
*
|
|
22
|
+
* TWO FIRST-CLASS ENTRY POINTS (mirrors reportSessionStart vs useSessionStart):
|
|
23
|
+
* • `reportFirstOpen(...)` / `wireLifecycleEvents(...)` — pure, React-free, for a host that owns
|
|
24
|
+
* its own app-open path.
|
|
25
|
+
* • `useLifecycleEvents(...)` — the batteries-included React hook (in `useLifecycleEvents.ts`).
|
|
26
|
+
*
|
|
27
|
+
* OFFLINE-FIRST: pass the Brief-01 event queue's `enqueue` as the `sink` and every lifecycle event
|
|
28
|
+
* is buffered + persisted + retried instead of a blind fetch. Without a sink they degrade to a
|
|
29
|
+
* direct fire-and-forget POST.
|
|
30
|
+
*
|
|
31
|
+
* PRIVACY (hard rule, same as the rest of the kit): NO GPS/location, NO advertising id. Nothing
|
|
32
|
+
* here carries a coordinate or an ad id — a host adopting it changes no store privacy declaration.
|
|
33
|
+
*
|
|
34
|
+
* FIRE-AND-FORGET: like every analytics path in the kit, nothing here throws into the UI, awaits
|
|
35
|
+
* in the caller, or hangs the app — storage reads race a short timeout, writes swallow errors.
|
|
36
|
+
*/
|
|
37
|
+
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
38
|
+
import type { DeviceContext } from "../device/deviceContext";
|
|
39
|
+
import { sanitizeUserId } from "../identity/userIdentity";
|
|
40
|
+
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
41
|
+
import { reportSessionStart, SESSION_STARTED_EVENT } from "./reportSessionStart";
|
|
42
|
+
|
|
43
|
+
/** The canonical event name for the first-ever app open. Same `app.*` namespace as
|
|
44
|
+
* {@link SESSION_STARTED_EVENT}; a trigger keys off this exact string. */
|
|
45
|
+
export const FIRST_OPEN_EVENT = "app.first_open" as const;
|
|
46
|
+
|
|
47
|
+
/** Storage key for the once-ever first-open flag, e.g. `wireai:first_open:myelino`. Mirrors the
|
|
48
|
+
* `wireai:<concern>:<appId>` namespacing of {@link sessionStorageKey}. */
|
|
49
|
+
export const firstOpenStorageKey = (appId: string): string => `wireai:first_open:${appId}`;
|
|
50
|
+
|
|
51
|
+
/** Ceiling on the flag read — a hung adapter degrades to the in-memory latch, never a stuck gate. */
|
|
52
|
+
const READ_TIMEOUT_MS = 1_500;
|
|
53
|
+
|
|
54
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
|
|
55
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
56
|
+
const timeout = new Promise<undefined>((resolve) => {
|
|
57
|
+
timer = setTimeout(() => resolve(undefined), ms);
|
|
58
|
+
});
|
|
59
|
+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ── First-open in-memory latch ───────────────────────────────────────────────
|
|
63
|
+
// Covers TWO cases the persisted flag can't: (1) two near-simultaneous calls in one process (the
|
|
64
|
+
// flag read is async, so a synchronous latch is what guarantees at-most-one before any await), and
|
|
65
|
+
// (2) the documented DEGRADED no-storage mode (fires once per process). Keyed by appId.
|
|
66
|
+
const _firstOpenLatched = new Set<string>();
|
|
67
|
+
|
|
68
|
+
/** Test-only: forget the first-open latch so a unit test starts from a clean process state. */
|
|
69
|
+
export const resetFirstOpenLatch = (): void => {
|
|
70
|
+
_firstOpenLatched.clear();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Shared inputs for a lifecycle event. Everything is optional except a transport (`target` for the
|
|
74
|
+
* direct-POST fallback, or a `sink`). A pre-auth open (no user yet) is a valid device-only event. */
|
|
75
|
+
export interface LifecycleEventInput {
|
|
76
|
+
/** Where to POST if no `sink` is wired (the tenant transport, same as `WireOnboardingConfig`). */
|
|
77
|
+
target?: ClientEventTarget;
|
|
78
|
+
/** Preferred transport: route the built event HERE (the offline queue's `enqueue`). */
|
|
79
|
+
sink?: (event: ClientEvent) => void;
|
|
80
|
+
/** The per-open session id. Defaults to a fresh `makeSessionId()`. */
|
|
81
|
+
sessionId?: string;
|
|
82
|
+
/** The host's OPAQUE pseudonymous user id (NOT PII). Sanitized + capped; omitted pre-auth. */
|
|
83
|
+
userId?: string;
|
|
84
|
+
/** A stable, non-PII device id the host owns. Rides in `user_context.device_key`. */
|
|
85
|
+
deviceKey?: string;
|
|
86
|
+
/** The host's local open-counter value. Drives `returning` + "Nth session". */
|
|
87
|
+
sessionCount?: number;
|
|
88
|
+
/** Host app version (e.g. "1.4.2"), if cheaply available. */
|
|
89
|
+
appVersion?: string;
|
|
90
|
+
/** Platform string (e.g. "ios"), if cheaply available. */
|
|
91
|
+
platform?: string;
|
|
92
|
+
/** An optional richer device snapshot (from `collectDeviceContext()`); the hook fills this. */
|
|
93
|
+
device?: DeviceContext;
|
|
94
|
+
/** Small non-PII extras, stored as a JSON string in the event `meta`. */
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Options for {@link reportFirstOpen}. Adds the once-ever persistence inputs on top of the shared
|
|
99
|
+
* lifecycle inputs. Without `storage` it degrades to the in-memory latch (once per process). */
|
|
100
|
+
export interface ReportFirstOpenOptions extends LifecycleEventInput {
|
|
101
|
+
/** Host persistence (AsyncStorage subset). The once-ever flag lives here — it is what survives an
|
|
102
|
+
* app kill. Omit it for the documented degraded (in-memory, once-per-process) mode. */
|
|
103
|
+
storage?: WireOnboardingStorage;
|
|
104
|
+
/** Tenant/app id — namespaces the persisted flag (`wireai:first_open:<appId>`). */
|
|
105
|
+
appId?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the `app_event` payload shared by `app.first_open` and `app.session_started`. This is a
|
|
110
|
+
* BYTE-FOR-BYTE mirror of the field logic in {@link reportSessionStart} — same `event_type`, same
|
|
111
|
+
* `question_key`-as-name contract, same `user_context` bucket, same optional fields — so the two
|
|
112
|
+
* lifecycle events are one contract with two names. Do NOT let these diverge (see lifecycle.test).
|
|
113
|
+
*/
|
|
114
|
+
const buildLifecycleEvent = (questionKey: string, opts: LifecycleEventInput): Record<string, unknown> => {
|
|
115
|
+
const userContext: Record<string, string | number | boolean> = {};
|
|
116
|
+
if (opts.deviceKey) userContext.device_key = opts.deviceKey;
|
|
117
|
+
if (typeof opts.sessionCount === "number" && Number.isFinite(opts.sessionCount)) {
|
|
118
|
+
userContext.session_count = opts.sessionCount;
|
|
119
|
+
userContext.returning = opts.sessionCount > 1;
|
|
120
|
+
}
|
|
121
|
+
if (opts.appVersion) userContext.app_version = opts.appVersion;
|
|
122
|
+
if (opts.platform) userContext.platform = opts.platform;
|
|
123
|
+
|
|
124
|
+
const event: Record<string, unknown> = {
|
|
125
|
+
event_type: "app_event",
|
|
126
|
+
question_key: questionKey,
|
|
127
|
+
session_id: opts.sessionId ?? makeSessionId(),
|
|
128
|
+
};
|
|
129
|
+
const userId = sanitizeUserId(opts.userId);
|
|
130
|
+
if (userId) event.user_id = userId;
|
|
131
|
+
if (Object.keys(userContext).length > 0) event.user_context = userContext;
|
|
132
|
+
if (opts.device) event.device = opts.device;
|
|
133
|
+
if (opts.meta && Object.keys(opts.meta).length > 0) event.meta = JSON.stringify(opts.meta);
|
|
134
|
+
return event;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Route a built event to the wired sink, else a direct fire-and-forget POST. Never throws. */
|
|
138
|
+
const routeLifecycleEvent = (event: Record<string, unknown>, opts: LifecycleEventInput): void => {
|
|
139
|
+
try {
|
|
140
|
+
if (opts.sink) {
|
|
141
|
+
opts.sink(event as ClientEvent);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const target = opts.target;
|
|
145
|
+
if (!target?.serverUrl) return;
|
|
146
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
147
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
148
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
149
|
+
void fetch(url, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify({ events: [event] }),
|
|
153
|
+
}).catch(() => {
|
|
154
|
+
// Network/transport error — analytics is best-effort, swallow.
|
|
155
|
+
});
|
|
156
|
+
} catch {
|
|
157
|
+
// A throwing sink, URL/JSON error, or a missing fetch — swallow.
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const emitFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
162
|
+
routeLifecycleEvent(buildLifecycleEvent(FIRST_OPEN_EVENT, opts), opts);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Emit `app.first_open` EXACTLY ONCE EVER per install. Fire-and-forget; returns immediately.
|
|
167
|
+
*
|
|
168
|
+
* • With `storage`: reads the persisted flag (`wireai:first_open:<appId>`). Absent → emit, then
|
|
169
|
+
* write the flag (survives app kills, so a second launch is a no-op). Present → no-op.
|
|
170
|
+
* • Race guard: an in-memory latch is set SYNCHRONOUSLY before the async read, so two
|
|
171
|
+
* near-simultaneous calls fire at most once.
|
|
172
|
+
* • Without `storage`: degraded mode — fires once per PROCESS via the latch only (documented).
|
|
173
|
+
*/
|
|
174
|
+
export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
175
|
+
const appId = opts.appId ?? "default";
|
|
176
|
+
|
|
177
|
+
// Synchronous latch FIRST: guarantees at-most-one before any await (same-process race guard) and
|
|
178
|
+
// is the sole guard in the no-storage degraded mode.
|
|
179
|
+
if (_firstOpenLatched.has(appId)) return;
|
|
180
|
+
_firstOpenLatched.add(appId);
|
|
181
|
+
|
|
182
|
+
const storage = opts.storage;
|
|
183
|
+
if (!storage) {
|
|
184
|
+
// Degraded: no persistence to survive a kill → the latch fires it once per process.
|
|
185
|
+
emitFirstOpen(opts);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const key = firstOpenStorageKey(appId);
|
|
190
|
+
void (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const seen = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
|
|
193
|
+
// A prior launch already fired + wrote the flag → once-ever satisfied, no-op.
|
|
194
|
+
if (seen) return;
|
|
195
|
+
emitFirstOpen(opts);
|
|
196
|
+
try {
|
|
197
|
+
// Best-effort persist: a failed write just means the next launch may re-fire once.
|
|
198
|
+
void storage.setItem(key, JSON.stringify({ ts: Date.now() })).catch(() => {});
|
|
199
|
+
} catch {
|
|
200
|
+
// Missing/broken storage write — swallow.
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// Unreadable flag → the in-memory latch still caps us at one fire this process.
|
|
204
|
+
emitFirstOpen(opts);
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Options for {@link wireLifecycleEvents}: the shared lifecycle inputs + first-open persistence. */
|
|
210
|
+
export interface WireLifecycleOptions extends ReportFirstOpenOptions {}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Wire BOTH lifecycle events in one call for a host that owns its own app-open path (the non-hook
|
|
214
|
+
* counterpart to {@link useLifecycleEvents}). Fires `app.first_open` (once ever) and one
|
|
215
|
+
* `app.session_started` for THIS open through the EXISTING {@link reportSessionStart} emitter (so
|
|
216
|
+
* the once-per-open guard still applies — pass the same `sessionId` and it never double-fires).
|
|
217
|
+
* Route both through the same `sink` (the offline queue) to buffer them. Fire-and-forget.
|
|
218
|
+
*/
|
|
219
|
+
export const wireLifecycleEvents = (opts: WireLifecycleOptions): void => {
|
|
220
|
+
reportFirstOpen(opts);
|
|
221
|
+
reportSessionStart({
|
|
222
|
+
target: opts.target,
|
|
223
|
+
sink: opts.sink,
|
|
224
|
+
sessionId: opts.sessionId,
|
|
225
|
+
userId: opts.userId,
|
|
226
|
+
deviceKey: opts.deviceKey,
|
|
227
|
+
sessionCount: opts.sessionCount,
|
|
228
|
+
appVersion: opts.appVersion,
|
|
229
|
+
platform: opts.platform,
|
|
230
|
+
device: opts.device,
|
|
231
|
+
meta: opts.meta,
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Re-exported so callers wiring lifecycle events have the session name alongside the first-open one.
|
|
236
|
+
export { SESSION_STARTED_EVENT };
|
|
@@ -31,7 +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 { makeSessionId, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
34
|
+
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
35
35
|
import type { DeviceContext } from "../device/deviceContext";
|
|
36
36
|
import { sanitizeUserId } from "../identity/userIdentity";
|
|
37
37
|
|
|
@@ -68,6 +68,15 @@ export interface ReportSessionStartOptions {
|
|
|
68
68
|
meta?: Record<string, unknown>;
|
|
69
69
|
/** Set `false` to bypass the once-per-open guard (default on). See {@link resetSessionStartGuard}. */
|
|
70
70
|
once?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* OPTIONAL transport sink. When provided, the built `app.session_started` event is routed HERE
|
|
73
|
+
* (e.g. the offline-first event queue's `enqueue`) INSTEAD of this emitter's own direct `fetch`,
|
|
74
|
+
* while KEEPING the once-per-open guard above. This is how the lifecycle wiring
|
|
75
|
+
* (`useLifecycleEvents` / `wireLifecycleEvents`) offline-buffers session-start WITHOUT adding a
|
|
76
|
+
* second session emitter: one emitter, one guard, now durable. Omit it for the direct-POST path.
|
|
77
|
+
* A throwing sink is swallowed — analytics must never surface into the UI.
|
|
78
|
+
*/
|
|
79
|
+
sink?: (event: ClientEvent) => void;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
// ── Once-per-open guard ──────────────────────────────────────────────────────
|
|
@@ -89,7 +98,9 @@ export const resetSessionStartGuard = (): void => {
|
|
|
89
98
|
*/
|
|
90
99
|
export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
91
100
|
const target = opts.target;
|
|
92
|
-
|
|
101
|
+
// A wired sink is a valid transport on its own — it owns the target (the queue's serverUrl), so
|
|
102
|
+
// this emitter needs its own `target` ONLY for the direct-fetch fallback.
|
|
103
|
+
if (!opts.sink && !target?.serverUrl) return;
|
|
93
104
|
|
|
94
105
|
const sessionId = opts.sessionId ?? makeSessionId();
|
|
95
106
|
|
|
@@ -105,10 +116,6 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
try {
|
|
108
|
-
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
109
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
110
|
-
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
111
|
-
|
|
112
119
|
// Non-PII correlation bucket. device_key is read by the server to group a device's sessions;
|
|
113
120
|
// session_count / returning / app_version / platform feed per-user retention analytics.
|
|
114
121
|
const userContext: Record<string, string | number | boolean> = {};
|
|
@@ -131,6 +138,19 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
131
138
|
if (opts.device) event.device = opts.device;
|
|
132
139
|
if (opts.meta && Object.keys(opts.meta).length > 0) event.meta = JSON.stringify(opts.meta);
|
|
133
140
|
|
|
141
|
+
// Preferred transport: an injected sink (the offline-first event queue's `enqueue`), which
|
|
142
|
+
// buffers + persists + retries. The once-guard above already ran, so this stays the single
|
|
143
|
+
// session emitter with a single guard — now durable.
|
|
144
|
+
if (opts.sink) {
|
|
145
|
+
opts.sink(event as ClientEvent);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fallback: this emitter's own direct POST when no sink is wired.
|
|
150
|
+
if (!target?.serverUrl) return;
|
|
151
|
+
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
152
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
153
|
+
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
134
154
|
void fetch(url, {
|
|
135
155
|
method: "POST",
|
|
136
156
|
headers,
|
|
@@ -139,6 +159,6 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
|
|
|
139
159
|
// Network/transport error — analytics is best-effort, swallow.
|
|
140
160
|
});
|
|
141
161
|
} catch {
|
|
142
|
-
// URL construction, JSON serialization, or a missing fetch — swallow.
|
|
162
|
+
// URL construction, JSON serialization, a throwing sink, or a missing fetch — swallow.
|
|
143
163
|
}
|
|
144
164
|
};
|