@wireai/activation 0.10.0 → 0.12.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.
Files changed (61) hide show
  1. package/AGENTS.md +51 -0
  2. package/CHANGELOG.md +111 -1
  3. package/INTEGRATION_PROMPT.md +13 -1
  4. package/README.md +106 -4
  5. package/dist/analytics/index.d.mts +35 -6
  6. package/dist/analytics/index.d.ts +35 -6
  7. package/dist/analytics/index.js +222 -94
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +214 -95
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-D0Vq7_VE.d.ts → currentSession-D6RiVtc8.d.ts} +187 -29
  12. package/dist/{currentSession-DdDkprpM.d.mts → currentSession-DsSDHqor.d.mts} +187 -29
  13. package/dist/index.d.mts +236 -82
  14. package/dist/index.d.ts +236 -82
  15. package/dist/index.js +330 -60
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +314 -61
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.d.mts +1 -1
  20. package/dist/questionnaire/index.d.ts +1 -1
  21. package/dist/questionnaire/index.js +59 -8
  22. package/dist/questionnaire/index.js.map +1 -1
  23. package/dist/questionnaire/index.mjs +59 -8
  24. package/dist/questionnaire/index.mjs.map +1 -1
  25. package/dist/reviews/index.d.mts +2 -2
  26. package/dist/reviews/index.d.ts +2 -2
  27. package/dist/reviews/index.js +97 -17
  28. package/dist/reviews/index.js.map +1 -1
  29. package/dist/reviews/index.mjs +97 -17
  30. package/dist/reviews/index.mjs.map +1 -1
  31. package/dist/{transport-Bzb-bcB2.d.mts → transport-CF_eHwzC.d.mts} +15 -16
  32. package/dist/{transport-B31G0Cib.d.ts → transport-DsRe4epC.d.ts} +15 -16
  33. package/llms.txt +9 -0
  34. package/package.json +1 -1
  35. package/src/activation/useWireActivation.ts +12 -1
  36. package/src/activation/wireActivation.ts +36 -24
  37. package/src/analytics/analyticsFacade.ts +113 -29
  38. package/src/analytics/currentSession.ts +83 -0
  39. package/src/analytics/eventQueue.ts +20 -11
  40. package/src/analytics/index.ts +29 -1
  41. package/src/analytics/reportClientEvent.ts +50 -2
  42. package/src/analytics/screenTracking.ts +6 -1
  43. package/src/analytics/useAnalytics.ts +22 -1
  44. package/src/context/deviceId.ts +109 -0
  45. package/src/context/userContext.ts +73 -0
  46. package/src/identity/userIdentity.ts +10 -0
  47. package/src/index.ts +50 -2
  48. package/src/questionnaire/runtime.ts +12 -2
  49. package/src/questionnaire/transport.ts +5 -1
  50. package/src/questionnaire/useQuestionnaireGate.ts +9 -7
  51. package/src/revenuecat/index.ts +55 -0
  52. package/src/revenuecat/purchaseEvents.ts +167 -0
  53. package/src/revenuecat/revenueCatBridge.ts +221 -0
  54. package/src/revenuecat/types.ts +95 -0
  55. package/src/reviews/decision.ts +8 -1
  56. package/src/reviews/runtime.ts +92 -1
  57. package/src/reviews/transport.ts +27 -10
  58. package/src/reviews/useReviewGate.ts +12 -7
  59. package/src/session-analytics/lifecycle.ts +15 -13
  60. package/src/session-analytics/reportSessionStart.ts +15 -4
  61. package/src/session-analytics/useLifecycleEvents.ts +68 -28
@@ -12,6 +12,8 @@
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 { ensureCurrentSessionId } from "../analytics/currentSession";
16
+ import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
15
17
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
16
18
 
17
19
  /**
@@ -156,7 +158,11 @@ export const fetchReviewDecision = async (
156
158
 
157
159
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
158
160
  export interface ReportAppEventOptions {
159
- /** The onboarding/session id to correlate with, when known. */
161
+ /**
162
+ * The onboarding/session id to correlate with, when known. Optional: when omitted the event
163
+ * still carries the CURRENT per-open session id (`ensureCurrentSessionId()`), because an event
164
+ * with no `session_id` is dropped server-side behind a 200. Pass one only to override.
165
+ */
160
166
  sessionId?: string;
161
167
  /** A stable, non-PII device id — the review-decision endpoint reads it for min-sessions. */
162
168
  deviceKey?: string;
@@ -170,6 +176,16 @@ export interface ReportAppEventOptions {
170
176
  * stable identifier and `meta` small + non-PII.
171
177
  *
172
178
  * reportAppEvent(target, "content_share", { sessionId, deviceKey });
179
+ *
180
+ * ── `session_id` IS NON-NEGOTIABLE ON THE WIRE ───────────────────────────────────────────
181
+ * The server's event model declares `session_id` required + non-empty, and `POST /v1/events`
182
+ * validates per event inside a try/except that counts the failure as `skipped` and STILL returns
183
+ * HTTP 200. An event sent without a `session_id` is therefore accepted and discarded, and a
184
+ * fire-and-forget caller never finds out. This used to be reachable through the ordinary API:
185
+ * `options.sessionId` was optional, so a host calling `reportAppEvent(target, "screen", { deviceKey })`
186
+ * posted every screen view into that hole. So the id is no longer conditional — an explicit
187
+ * `sessionId` wins, otherwise the CURRENT per-open id is used (minted + registered if no app-open
188
+ * has been registered yet).
173
189
  */
174
190
  export const reportAppEvent = (
175
191
  target: ReviewTarget | undefined,
@@ -178,25 +194,26 @@ export const reportAppEvent = (
178
194
  ): void => {
179
195
  if (!target?.serverUrl || !name) return;
180
196
  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}`;
197
+ // An explicit id wins; a missing OR BLANK one falls back to the current per-open id. A bare
198
+ // `??` would let `sessionId: ""` through, and the server rejects an empty string exactly like
199
+ // a missing key (`min_length=1`), so the blank case has to fall back too.
200
+ const supplied = options.sessionId ?? "";
184
201
  const event: Record<string, unknown> = {
185
202
  event_type: "app_event",
186
203
  question_key: name,
204
+ session_id: supplied.trim().length > 0 ? supplied : ensureCurrentSessionId(),
187
205
  };
188
- if (options.sessionId) event.session_id = options.sessionId;
189
206
  // device_key rides in the non-PII user_context bucket the server sanitizes; the
190
207
  // review-decision endpoint reads it to group a device's sessions.
191
208
  if (options.deviceKey) event.user_context = { device_key: options.deviceKey };
192
209
  if (options.meta && Object.keys(options.meta).length > 0) {
193
210
  event.meta = JSON.stringify(options.meta);
194
211
  }
195
- void fetch(url, {
196
- method: "POST",
197
- headers,
198
- body: JSON.stringify({ events: [event] }),
199
- }).catch(() => {
212
+ // Route through the ONE canonical /v1/events builder (url + headers + body) instead of
213
+ // re-describing the endpoint here; still fire-and-forget.
214
+ const req = buildEventsRequest(target, [event as unknown as ClientEvent]);
215
+ if (!req) return;
216
+ void fetch(req.url, req.init).catch(() => {
200
217
  /* best-effort, swallow */
201
218
  });
202
219
  } catch {
@@ -19,10 +19,12 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
19
19
  import { decideReview, evaluateGate, resolveRules } from "./decision";
20
20
  import { sameDecision, shallowEqual } from "./equality";
21
21
  import {
22
+ bumpSessionCount,
22
23
  readInt,
23
24
  resolveStorage,
24
25
  reviewLastShownKey,
25
26
  reviewSeenKey,
27
+ reviewSessionOpenKey,
26
28
  reviewSessionsKey,
27
29
  writeInt,
28
30
  } from "./runtime";
@@ -70,14 +72,17 @@ export const useReviewGate = ({
70
72
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
71
73
  const lastKey = reviewLastShownKey(config.id);
72
74
  const sessionsKey = reviewSessionsKey(config.id);
75
+ const sessionOpenKey = reviewSessionOpenKey(config.id);
73
76
 
74
- // Read (and bump) the session counter ONCE per mount: this mount is a new session.
75
- const sessions = useState(() => {
76
- const store = resolveStorage(storage);
77
- const next = readInt(store, sessionsKey) + 1;
78
- writeInt(store, sessionsKey, next);
79
- return next;
80
- })[0];
77
+ // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
78
+ // keys off the live per-open session id (or a per-process id when no host wired the lifecycle
79
+ // events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
80
+ // initializer all read the same number back instead of inflating it. Before this, `sessions`
81
+ // counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
82
+ // very first app open — the exact scenario it was added to prevent.
83
+ const sessions = useState(() =>
84
+ bumpSessionCount(resolveStorage(storage), sessionsKey, sessionOpenKey),
85
+ )[0];
81
86
 
82
87
  // Gate the local rules behind an optional client-side timeout, so a reachable server
83
88
  // gets a window to answer first. A present `decision` bypasses the wait entirely.
@@ -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
- 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(() => {
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 {
@@ -217,11 +212,18 @@ export interface WireLifecycleOptions extends ReportFirstOpenOptions {}
217
212
  * Route both through the same `sink` (the offline queue) to buffer them. Fire-and-forget.
218
213
  */
219
214
  export const wireLifecycleEvents = (opts: WireLifecycleOptions): void => {
220
- reportFirstOpen(opts);
215
+ // ONE per-open id for BOTH events when the caller supplies none. Without this, `reportFirstOpen`
216
+ // fell through to `buildLifecycleEvent`'s own `makeSessionId()` while `reportSessionStart` minted a
217
+ // DIFFERENT one — so `app.first_open` carried a session id the server never saw a `session_started`
218
+ // for and back-filled into a phantom session, inflating session counts. `useLifecycleEvents` already
219
+ // pinned this (it mints `mountOpenSessionId`); the React-free entry point did not, so the identical
220
+ // bug was still reachable from the documented non-hook path.
221
+ const sessionId = opts.sessionId ?? makeSessionId();
222
+ reportFirstOpen({ ...opts, sessionId });
221
223
  reportSessionStart({
222
224
  target: opts.target,
223
225
  sink: opts.sink,
224
- sessionId: opts.sessionId,
226
+ sessionId,
225
227
  userId: opts.userId,
226
228
  deviceKey: opts.deviceKey,
227
229
  sessionCount: opts.sessionCount,
@@ -32,7 +32,12 @@
32
32
  * never be able to break the app.
33
33
  */
34
34
  import { setCurrentSessionId } from "../analytics/currentSession";
35
- import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
+ import {
36
+ makeSessionId,
37
+ warnOnSkippedEvents,
38
+ type ClientEvent,
39
+ type ClientEventTarget,
40
+ } from "../analytics/reportClientEvent";
36
41
  import type { DeviceContext } from "../device/deviceContext";
37
42
  import { sanitizeUserId } from "../identity/userIdentity";
38
43
 
@@ -162,9 +167,15 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
162
167
  method: "POST",
163
168
  headers,
164
169
  body: JSON.stringify({ events: [event] }),
165
- }).catch(() => {
166
- // Network/transport error — analytics is best-effort, swallow.
167
- });
170
+ })
171
+ .then((res) => {
172
+ // A 200 can still carry `skipped:N` — the server took the request and threw the event away.
173
+ // Log-only; this path has nothing to retry either way.
174
+ warnOnSkippedEvents(res);
175
+ })
176
+ .catch(() => {
177
+ // Network/transport error — analytics is best-effort, swallow.
178
+ });
168
179
  } catch {
169
180
  // URL construction, JSON serialization, a throwing sink, or a missing fetch — swallow.
170
181
  }
@@ -28,7 +28,8 @@ 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 { ClientEvent, ClientEventTarget } from "../analytics/reportClientEvent";
31
+ import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
32
+ import { resolveAutoDeviceKey } from "../context/deviceId";
32
33
  import { collectDeviceContext } from "../device/deviceContext";
33
34
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
35
  import { reportFirstOpen } from "./lifecycle";
@@ -114,7 +115,70 @@ export const useLifecycleEvents = (
114
115
  const targetOf = (cfg: LifecycleConfig | undefined): ClientEventTarget | undefined =>
115
116
  cfg?.serverUrl ? { serverUrl: cfg.serverUrl, apiKey: cfg.apiKey ?? "" } : undefined;
116
117
 
117
- // 1) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen).
118
+ /**
119
+ * The `device_key` these lifecycle events ride under. A host-supplied id always wins.
120
+ *
121
+ * WHY THE FALLBACK EXISTS: `min_sessions` (the review / questionnaire firing rule, and the
122
+ * "fire on the user's Nth session" recipe in the README) is computed SERVER-SIDE by counting
123
+ * distinct `app.session_started` events grouped by `user_context.device_key`. A host that took
124
+ * the batteries-included path and passed no `deviceKey` emitted those events with NO device key
125
+ * at all — while its `createAnalytics` / `createWireActivation` events carried an auto-minted
126
+ * one. Two disjoint identity spaces again: the counter the rule reads could never increase, so
127
+ * `min_sessions` was structurally unsatisfiable and the gate never fired from the server side.
128
+ *
129
+ * ONLY WITH `storage`: the auto id is per-INSTALL only when it can be persisted. With no
130
+ * storage it would be per-LAUNCH, which would make every open look like a brand-new device and
131
+ * corrupt `min_sessions` in the other direction. So no storage → no fallback, same as before.
132
+ */
133
+ const resolveDeviceKey = (
134
+ cfg: LifecycleConfig | undefined,
135
+ opts: UseLifecycleEventsOptions,
136
+ ): string | undefined => {
137
+ const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
138
+ if (host) return host;
139
+ if (!cfg?.storage) return undefined;
140
+ return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
141
+ };
142
+
143
+ // ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
144
+ // WHY: `first_open` used to default to its OWN fresh `makeSessionId()` (via `buildLifecycleEvent`)
145
+ // while `session_started` minted a different one — so every install's `first_open` carried a
146
+ // session id the server never got a `session_started` for (a PHANTOM session the server
147
+ // back-filled). Minting one id here and handing it to both makes them agree, so the server sees
148
+ // `first_open` under the same session it saw `session_started` for. A later foreground open
149
+ // (below) mints its OWN fresh id — a new open is a new session.
150
+ const mountOpenSessionId = makeSessionId();
151
+
152
+ // session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
153
+ // Accepts the per-open id so the mount open reuses `mountOpenSessionId`; omit it and the emitter
154
+ // mints a fresh id (a genuinely new open). Either way the once-guard dedupes within the open.
155
+ const fireSession = (sessionId?: string) => {
156
+ const { config: cfg, options: opts } = latest.current;
157
+ if (opts.enabled === false) return;
158
+ if (!cfg?.serverUrl && !opts.sink) return;
159
+ const device = collectDeviceContext();
160
+ // `device.appVersion` is auto-detected best-effort; an explicit host version always wins.
161
+ if (cfg?.appVersion) device.appVersion = cfg.appVersion;
162
+ reportSessionStart({
163
+ target: targetOf(cfg),
164
+ sink: resolveSink(),
165
+ sessionId,
166
+ userId: opts.userId,
167
+ deviceKey: resolveDeviceKey(cfg, opts),
168
+ sessionCount: opts.sessionCount,
169
+ appVersion: cfg?.appVersion ?? device.appVersion,
170
+ platform: Platform.OS,
171
+ device,
172
+ meta: opts.meta,
173
+ });
174
+ };
175
+
176
+ // 1) session_start FIRST (mount = an app-open), so the server has ingested `session_started`
177
+ // for `mountOpenSessionId` before first_open references the same id.
178
+ fireSession(mountOpenSessionId);
179
+
180
+ // 2) first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to
181
+ // the SAME per-open id so it is never a phantom session.
118
182
  {
119
183
  const { config: cfg, options: opts } = latest.current;
120
184
  if (opts.enabled !== false) {
@@ -124,10 +188,11 @@ export const useLifecycleEvents = (
124
188
  reportFirstOpen({
125
189
  target: targetOf(cfg),
126
190
  sink: resolveSink(),
191
+ sessionId: mountOpenSessionId,
127
192
  storage: cfg?.storage,
128
193
  appId: cfg?.appId,
129
194
  userId: opts.userId,
130
- deviceKey: opts.deviceKey,
195
+ deviceKey: resolveDeviceKey(cfg, opts),
131
196
  sessionCount: opts.sessionCount,
132
197
  appVersion: cfg?.appVersion ?? device.appVersion,
133
198
  platform: Platform.OS,
@@ -137,31 +202,6 @@ export const useLifecycleEvents = (
137
202
  }
138
203
  }
139
204
 
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
205
  // Foreground after a real background = a new app-open.
166
206
  let backgroundedAt: number | null = null;
167
207
  const onChange = (state: AppStateStatus) => {